diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59f9799..f992861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,14 @@ jobs: env: TRASK_SKIP_BUILD: "1" + - name: Install Playwright Chromium (Discord embed harness) + run: pnpm exec playwright install chromium --with-deps + + - name: Discord /ask Playwright harness (offline) + run: pnpm trask:e2e:discord:playwright + env: + TRASK_SKIP_BUILD: "1" + - name: Lint (pazaak-world) run: pnpm --filter pazaak-world lint diff --git a/AGENTS.md b/AGENTS.md index a36f075..d550e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,8 @@ pnpm verify:trask-cli `pnpm verify:trask-cli` runs `scripts/verify_trask_cli_qa.mjs` (golden queries). `pnpm verify:trask-cli:ci` / `pnpm trask:verify-import-smoke:ci` use `--import-smoke` (no LLM). `pnpm verify:trask-discord` runs live Discord-format checks via the research wizard. +**Playwright (offline Discord + live Holocron):** `pnpm trask:e2e:discord:playwright` — static harness on **:4012** (`scripts/discord-ask-e2e-webserver.mjs`, `e2e/trask-discord-ask.spec.mjs`); mirrors import-smoke embed contract in a real browser (no discord.com, no LLM). `pnpm trask:e2e:playwright` runs Discord harness then `pnpm holocron:e2e:playwright`. CI Build job runs Discord Playwright after `trask:gate:ci`. + #### Offline faithfulness gate (citation alignment) After code changes to answer formatting, citation alignment, or `grounded-evidence.ts`, run: @@ -164,7 +166,7 @@ pnpm trask:faithfulness-eval # faithfulness fixtures only ### Trask Discord `/ask` — mandatory verification (agents) -**Do not claim Discord `/ask` is fixed until live checks pass.** Run `pnpm verify:trask-discord` (full expert set). Holocron requires `pnpm holocron:e2e` and, when browser MCP works, all five UI queries on `:4010`. Discord web UI is not Playwright-gated in CI; live verify exercises the same compose/format path as the bot. +**Do not claim Discord `/ask` is fixed until live checks pass.** Run `pnpm verify:trask-discord` (full expert set). Offline Playwright gate: `pnpm trask:e2e:discord:playwright` (five golden import-smoke embeds on **:4012**). Holocron requires `pnpm holocron:e2e` and, when browser MCP works, all five UI queries on `:4010`. CI runs offline Discord Playwright in Build; live `verify:trask-discord` still needs a bot token locally. 1. **Worker retrieve** at `TRASK_INDEXER_BASE_URL=http://127.0.0.1:8787` — `pnpm verify:trask-discord` auto-bootstraps indexer+Worker when unhealthy (same as CLI/Holocron e2e); manual path: `bash scripts/trask_live_stack.sh` with QA seed `bash scripts/bootstrap_trask_indexer.sh`, `bash scripts/trask_index_seed_for_qa.sh`. 2. **Restart** Trask bot after `@openkotor/trask` changes: kill old `trask-bot/dist/main.js`, rebuild (`pnpm build`), start with `TRASK_INDEXER_BASE_URL` + `TRASK_WEB_RESEARCH_PYTHON` + `.env` token. diff --git a/apps/holocron-web/e2e/holocron-research.spec.ts b/apps/holocron-web/e2e/holocron-research.spec.ts index 695b40a..a0e5e1c 100644 --- a/apps/holocron-web/e2e/holocron-research.spec.ts +++ b/apps/holocron-web/e2e/holocron-research.spec.ts @@ -205,6 +205,15 @@ for (const [index, querySpec] of RESEARCH_QUERIES.entries()) { assertSubstantiveAnswer(bodyText, sourcesText, querySpec) + expect(bodyText, 'answer should not leak spaced markdown link syntax').not.toMatch(/\]\s*\(https:\/\//) + if (hasSourcesPanel) { + const sourceLabels = await sourcesRegion.locator('[role="listitem"]').allInnerTexts() + for (const label of sourceLabels) { + expect(label, 'source card label').not.toMatch(/githubusercontent\.com/i) + expect(label, 'source card label').not.toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#L\d+$/i) + } + } + const httpsCount = Math.max( await httpsInSources.count(), countHttpsUrls(sourcesText), diff --git a/apps/holocron-web/package.json b/apps/holocron-web/package.json index e5e27cf..de8feb2 100644 --- a/apps/holocron-web/package.json +++ b/apps/holocron-web/package.json @@ -76,6 +76,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@openkotor/trask": "workspace:*", "@openkotor/trask-config": "workspace:*", "@eslint/js": "^9.21.0", "@tailwindcss/postcss": "^4.1.8", diff --git a/apps/holocron-web/src/lib/answer-presentation.ts b/apps/holocron-web/src/lib/answer-presentation.ts index ac0f281..eed1202 100644 --- a/apps/holocron-web/src/lib/answer-presentation.ts +++ b/apps/holocron-web/src/lib/answer-presentation.ts @@ -3,6 +3,8 @@ * Kept separate from Message.tsx for unit testing without React. */ +import { webCitationDisplayLabel } from '@openkotor/trask/github-citation-url' + export interface SourceLike { name: string url: string @@ -205,44 +207,8 @@ function sourceHostname(url: string): string { function formatSourceDisplayName(name: string, url: string): string { if (!url) return name - try { - const parsed = new URL(url) - const host = parsed.hostname.replace(/^www\./, '') - const genericName = - !name.trim() - || name.trim().toLowerCase() === host.toLowerCase() - || name.trim().toLowerCase() === 'github.com' - - if (host === 'github.com') { - const path = decodeURIComponent(parsed.pathname) - const hash = parsed.hash && /^#L/i.test(parsed.hash) ? parsed.hash : '' - const blobMatch = path.match(/\/blob\/[^/]+\/(.+)$/i) - if (blobMatch?.[1]) { - const shortPath = blobMatch[1].replace(/\/+$/, '').split('/').slice(-2).join('/') || blobMatch[1] - return `${shortPath}${hash}` - } - const wikiMatch = path.match(/\/wiki\/(.+)$/i) - if (wikiMatch?.[1]) { - const page = wikiMatch[1].replace(/\/+$/, '') - return `wiki: ${page.split('/').pop() ?? page}${hash}` - } - const repoMatch = path.match(/^\/([^/]+)\/([^/]+)\/?$/i) - if (repoMatch?.[2]) { - return `${repoMatch[2]}${hash}` - } - } - - if (genericName) { - const pathSegments = parsed.pathname.replace(/\/+$/, '').split('/').filter(Boolean) - if (pathSegments.length > 1) { - return `${pathSegments.slice(-2).join('/')}`.replace(/[-_]+/g, ' ') - } - return host - } - } catch { - /* keep name */ - } - return name + const label = webCitationDisplayLabel(url, name) + return label.trim() || name } export function sourceKey(source: Pick): string { @@ -263,7 +229,7 @@ function stripSourceNoise(text: string): string { function isNumberedBibliographyLine(line: string): boolean { const trimmed = line.trim() if (!parseNumberedSourceLine(trimmed)) return false - return extractHttpUrls(trimmed).length > 0 || trimmed.length > 24 + return extractHttpUrls(trimmed).length > 0 } /** @@ -430,11 +396,13 @@ export function buildAnswerPresentation(content: string, explicitSources: Source if (existingIndex !== undefined) { const existing = merged[existingIndex] if (!existing) return + const url = cleanUrl(candidate.url || existing.url) merged[existingIndex] = { ...existing, - name: existing.name || candidate.name, - url: existing.url || candidate.url, - hostname: existing.hostname || candidate.hostname, + ...candidate, + url, + name: formatSourceDisplayName(existing.name || candidate.name, url), + hostname: url ? sourceHostname(url) : existing.hostname || candidate.hostname, } return } diff --git a/docs/knowledgebase/10-architecture-runtime/trask-citation-display-contract.md b/docs/knowledgebase/10-architecture-runtime/trask-citation-display-contract.md index 761210a..f854b5e 100644 --- a/docs/knowledgebase/10-architecture-runtime/trask-citation-display-contract.md +++ b/docs/knowledgebase/10-architecture-runtime/trask-citation-display-contract.md @@ -66,7 +66,8 @@ pr_refs: [33, 34, 35, 36, 38] | Sources in UI | Visible panel / API fields | **Hidden** — stripped from embed | | Body presentation | Multi-paragraph / bullets allowed | ≤5 non-empty lines, char cap per line | | Citations | `[n]` + URLs in Sources | Inline `[n](https://…)` only | -| Display entry | HTTP record + Holocron UI | `buildResearchEmbed` + `formatDiscordAskDisplay` | +| Display entry | HTTP record + Holocron UI (`answer-presentation.ts` + `webCitationDisplayLabel`) | `buildResearchEmbed` + `formatDiscordAskDisplay` | +| GitHub source permalinks | `github-citation-url.ts` at compose; Holocron re-labels via `@openkotor/trask/github-citation-url` | Same module for embed URL map | [SYNTH] Faithfulness fixtures and Holocron e2e validate full answers; **discord-reply-format.test.js** stress tests validate the display transform only. diff --git a/docs/plans/2026-06-04-008-feat-holocron-citation-permalink-lfg-closeout-plan.md b/docs/plans/2026-06-04-008-feat-holocron-citation-permalink-lfg-closeout-plan.md new file mode 100644 index 0000000..bc019c9 --- /dev/null +++ b/docs/plans/2026-06-04-008-feat-holocron-citation-permalink-lfg-closeout-plan.md @@ -0,0 +1,79 @@ +--- +status: completed +branch: feat/holocron-topnav-ci-followup +origin: docs/plans/2026-06-04-007-feat-holocron-answer-citation-render-plan.md +--- + +# Plan: Holocron citation + GitHub permalink LFG closeout + +## Problem (pre-fix) + +Holocron grounded answers and the Sources panel misled users when: + +1. Answer body leaked raw markdown (`[label] (https://…)`) or numbered bibliography without a `Sources` heading. +2. GitHub shallow-repo citations inferred file paths from `raw.githubusercontent.com/...` URLs in passage text, producing blob paths like `githubusercontent.com/owner/repo` and labels like `KobaltBlu/KotOR.js#L1` instead of `README.md#L1`. + +**Remaining (this plan):** ship commits `ab117de` and `5c568d6` on `feat/holocron-topnav-ci-followup` after PR #97 merged to `main`. + +## Requirements + +| ID | Requirement | +|----|-------------| +| R1 | Visible answer paragraphs strip spaced markdown links/images; preserve `[n]` citation badges. | +| R2 | Trailing `1.` bibliography lines that include `https://` URLs, without a `Sources` heading, peel into the Sources panel (or source-only body). | +| R3 | `inferGitHubFilePath` ignores http(s) URL segments and rejects domain-like path segments. | +| R4 | Holocron Sources labels use `webCitationDisplayLabel` via `@openkotor/trask/github-citation-url` (no full `@openkotor/trask` browser bundle). | +| R5 | Unit tests cover markdown sanitization, permalink inference, and malformed-blob labels. | +| R6 | Open PR, CI green, local stack smoke on `:4010` for reone query. | + +## Implementation units + +### U1 — Answer presentation (landed `ab117de`) + +- **Files:** `apps/holocron-web/src/lib/answer-presentation.ts`, `apps/holocron-web/src/components/Message.tsx`, `scripts/answer_presentation.test.mjs` +- **Verification:** `node --import tsx/esm --test scripts/answer_presentation.test.mjs` + +### U1b — Compose markdown strip (landed `ab117de`) + +- **Files:** `packages/trask/src/grounded-evidence.ts` +- **Verification:** `node --test packages/trask/dist/grounded-evidence.test.js` (after `pnpm build`) + +### U2 — GitHub permalink labels (landed `5c568d6`) + +- **Files:** `packages/trask/src/github-citation-url.ts`, `packages/trask/package.json` (subpath export), `packages/trask/src/github-citation-url.test.ts`, `apps/holocron-web/package.json`, `apps/holocron-web/src/lib/answer-presentation.ts` +- **Verification:** `pnpm --filter @openkotor/trask build && node --test packages/trask/dist/github-citation-url.test.js` + +### U3 — Docs alignment (landed `5c568d6`) + +- **Files:** `docs/solutions/tooling-decisions/trask-citation-*.md`, `docs/knowledgebase/10-architecture-runtime/trask-citation-display-contract.md` +- **Verification:** paths in docs match repo; no contradictory "0 Playwright tests" claims. + +## Test scenarios + +- Passage with `raw.githubusercontent.com/.../icon.png` → blob URL ends with `/README.md`, label `README.md#Ln`. +- Answer with `3. [broken](https://raw...)` line → sanitized prose, no `](https://` in UI. +- Numbered-only body → Sources grid populated; no duplicate numbered paragraphs in answer. + +## Verification ladder + +```bash +pnpm build +node --import tsx/esm --test scripts/answer_presentation.test.mjs +node --test packages/trask/dist/github-citation-url.test.js +pnpm trask:gate:ci +bash scripts/trask_live_stack.sh +# Holocron :4010 — reone canonical query; Sources card shows README.md#Ln not owner/repo#Ln +``` + +## Scope boundaries + +- Out of scope: HF Space recovery, production Worker deploy (see `docs/plans/2026-06-04-006-fix-holocron-public-api-connection-plan.md`). +- Out of scope: Full five-query browser MCP gate in this PR; R6 closes with one reone smoke on `:4010`. Five-query Playwright e2e is optional post-merge (`pnpm holocron:e2e`). + +### U4 — Ship / verify (R6) + +- Open PR from `feat/holocron-topnav-ci-followup`, CI green, `:4010` reone canonical smoke. + +## Deferred to implementation + +- None — feature code landed; U4 tracks PR/CI/smoke only. diff --git a/docs/plans/2026-06-04-009-feat-trask-discord-playwright-holocron-e2e-plan.md b/docs/plans/2026-06-04-009-feat-trask-discord-playwright-holocron-e2e-plan.md new file mode 100644 index 0000000..52bfc0c --- /dev/null +++ b/docs/plans/2026-06-04-009-feat-trask-discord-playwright-holocron-e2e-plan.md @@ -0,0 +1,72 @@ +--- +status: completed +branch: feat/holocron-topnav-ci-followup +origin: docs/plans/2026-06-03-001-feat-trask-playwright-holocron-discord-e2e-plan.md +date: 2026-06-04 +--- + +# Plan: Trask Discord Playwright harness + Holocron citation e2e + +## Problem (pre-fix) + +Holocron has six Playwright tests (`holocron-research.spec.ts`), but Discord `/ask` contract validation is **Node-only** (`verify_trask_discord_live.mjs`). Agents and CI lack a **browser-level** Discord embed gate that uses Playwright while avoiding discord.com UI fragility and guild tokens. + +Holocron e2e does not assert **source card permalink labels** (`README.md#Ln`) after the citation presentation slice (PR #98). + +## Inferred Intent + +- **Direct ask:** Continue Trask Q&A quality with Playwright for Holocron and Discord surfaces; browser-verify both. +- **Adjacent impact:** CI ladder parity, import-smoke ↔ Playwright alignment, citation label regressions caught in e2e. +- **Cohesive scope:** Offline Discord Playwright harness (golden compose, no LLM); Holocron Playwright assertions for citation hygiene; root scripts + CI step; local full ladder run. +- **Risks if partial:** Discord regressions only caught by slow live verify; Holocron UI label bugs slip past Playwright. + +## Requirements + +| ID | Requirement | +|----|-------------| +| R1 | Playwright spec exercises all five `DISCORD_IMPORT_SMOKE_SPECS` embed descriptions via static harness (no discord.com, no LLM) | +| R2 | Harness assertions mirror `verify_trask_discord_live.mjs` import-smoke contract (lines, inline links, no Sources block, expectPattern) | +| R3 | Holocron Playwright asserts Sources panel labels avoid `githubusercontent.com` path noise | +| R4 | Root `package.json` exposes `trask:e2e:discord:playwright` and `trask:e2e:playwright` (Holocron + Discord) | +| R5 | CI runs Discord Playwright after `trask:gate:ci` (fast, offline) | +| R6 | Local: stack up → `pnpm trask:e2e:playwright` or Holocron-only with `HOLOCRON_REUSE_SERVER=1` | + +## Scope boundaries + +- **In:** Playwright harness server, specs, Holocron assertion tweak, package scripts, CI step. +- **Out:** Playwright against discord.com in CI; live Discord token in GitHub Actions UI tests. + +## Implementation units + +### U1 — Discord ask display audit module + +- **Files:** `scripts/lib/discord_ask_display_audit.mjs` (extract from verify script) +- **Tests:** existing import-smoke via `pnpm verify:trask-discord:ci` + +### U2 — Discord Playwright harness + +- **Files:** `scripts/discord-ask-e2e-webserver.mjs`, `e2e/trask-discord-ask.spec.mjs`, `playwright.trask-discord.config.mjs` +- **Verification:** `pnpm trask:e2e:discord:playwright` — 5 tests pass in <30s + +### U3 — Holocron citation Playwright assertions + +- **Files:** `apps/holocron-web/e2e/holocron-research.spec.ts` +- **Verification:** sources panel + answer body hygiene checks per query + +### U4 — Scripts + CI + +- **Files:** `package.json`, `.github/workflows/ci.yml` +- **Verification:** CI job step green + +### U5 — Local verification + +```bash +pnpm build && pnpm trask:gate:ci +pnpm trask:e2e:discord:playwright +bash scripts/trask_live_stack.sh +HOLOCRON_REUSE_SERVER=1 pnpm holocron:e2e:playwright +``` + +## Deferred + +- Optional headed Discord web UI proof (`discord_fetch_trask_token.mjs` profile) — operator-only. diff --git a/docs/plans/2026-06-04-010-feat-trask-e2e-ladder-closeout-plan.md b/docs/plans/2026-06-04-010-feat-trask-e2e-ladder-closeout-plan.md new file mode 100644 index 0000000..1ec5226 --- /dev/null +++ b/docs/plans/2026-06-04-010-feat-trask-e2e-ladder-closeout-plan.md @@ -0,0 +1,53 @@ +--- +status: completed +branch: feat/holocron-topnav-ci-followup +origin: docs/plans/2026-06-04-009-feat-trask-discord-playwright-holocron-e2e-plan.md +date: 2026-06-04 +--- + +# Plan: Trask e2e ladder closeout (Playwright + docs + CI) + +## Problem (remaining) + +Plan 009 landed Discord Playwright harness and Holocron citation assertions, but: + +1. `AGENTS.md` does not document `trask:e2e:discord:playwright` / `trask:e2e:playwright`. +2. Full Holocron Playwright matrix (6 tests) not re-run after citation assertion changes on PR #98. +3. PR #98 CI must go green with new Build job Discord Playwright step. + +## Inferred Intent + +- **Direct ask:** Complete Trask Q&A e2e story — Playwright for Holocron + Discord, browser validation. +- **Adjacent impact:** Agent runbooks, PR merge readiness, validation-ladder accuracy. +- **Cohesive scope:** Doc drift fix, full local Holocron Playwright pass, Discord offline Playwright + import-smoke, CI watch. +- **Risks if partial:** Agents miss new scripts; merge with unverified Holocron 6-test matrix. + +## Requirements + +| ID | Requirement | +|----|-------------| +| R1 | `AGENTS.md` documents `trask:e2e:discord:playwright`, `trask:e2e:playwright`, harness port 4012 | +| R2 | `pnpm trask:e2e:discord:playwright` passes locally | +| R3 | `HOLOCRON_REUSE_SERVER=1 pnpm holocron:e2e:playwright` — 6/6 pass on live stack | +| R4 | PR #98 Build & Test + Holocron Playwright jobs green | +| R5 | Browser smoke: one Holocron expert query on `:4010` (agent-browser) | + +## Implementation units + +### U1 — Agent docs + +- **Files:** `AGENTS.md` (Trask Discord / Holocron e2e sections) + +### U2 — Local Playwright verification + +- Discord: `pnpm trask:e2e:discord:playwright` +- Holocron: stack on `:4010` → `HOLOCRON_REUSE_SERVER=1 pnpm holocron:e2e:playwright` + +### U3 — CI + PR + +- Watch `gh pr checks 98`; autofix if Build job fails on Discord Playwright install/runtime + +## Deferred + +- Live `pnpm verify:trask-discord` (needs bot token in env) +- discord.com headed Playwright (operator-only) diff --git a/docs/solutions/tooling-decisions/trask-citation-module-architecture-2026-05-24.md b/docs/solutions/tooling-decisions/trask-citation-module-architecture-2026-05-24.md index 5b16b41..2ba738b 100644 --- a/docs/solutions/tooling-decisions/trask-citation-module-architecture-2026-05-24.md +++ b/docs/solutions/tooling-decisions/trask-citation-module-architecture-2026-05-24.md @@ -26,8 +26,10 @@ Authoritative display contract: [trask-citation-display-contract.md](../../knowl citation-markers.ts ← regex + parseCitationIndex (**internal** — not exported from `@openkotor/trask` index) query-anchor.ts ← BRIEF_DISCORD_MIN_CITATIONS, distinctiveAnchorTokens, claimMatchesQueryAnchor (**exported** from `@openkotor/trask` index) research-answer-split.ts ← splitResearchAnswer, syncSourcesSectionToApproved (**exported** from `@openkotor/trask` index) -grounded-evidence.ts ← compose, claims, sufficiency (imports split + anchor; re-exports anchor) +github-citation-url.ts ← shallow repo → blob permalinks, webCitationDisplayLabel, passage path inference (**exported**) +grounded-evidence.ts ← compose, claims, sufficiency (imports split + anchor + github-citation-url; re-exports anchor) discord-reply-format.ts ← line filters, embedInlineCitationLinks (imports markers, anchor, split; NOT grounded-evidence) +apps/holocron-web/src/lib/answer-presentation.ts ← Holocron Sources panel labels (`webCitationDisplayLabel` via `@openkotor/trask/github-citation-url` — browser-safe subpath) ``` [SYNTH] **No cycle** on answer parsing (split) or display anchors (query-anchor). Compose still owns claim selection; display imports anchor helpers only. diff --git a/docs/solutions/tooling-decisions/trask-citation-stack-closeout-2026-05-24.md b/docs/solutions/tooling-decisions/trask-citation-stack-closeout-2026-05-24.md index 5973a62..ea76dc0 100644 --- a/docs/solutions/tooling-decisions/trask-citation-stack-closeout-2026-05-24.md +++ b/docs/solutions/tooling-decisions/trask-citation-stack-closeout-2026-05-24.md @@ -48,7 +48,7 @@ Discord `/ask` brief embeds could collapse to a single inline citation after agg | CI import-smoke consolidation | #77 | `trask:verify-import-smoke:ci` in Actions; ladder doc sync | | Arc #33–#77 doc closeout | #78 | README `trask:gate:ci`; module-arch gate table; Holocron CI retries in ladder | | Holocron honest grounding UX | #79 | `partial`→`failed` inference/UI; faithfulness fixtures tracked; Node 24 test reporter in gate | -| Free LLM quality-first failover (plan 118 / PR #92) | #92 | CURATED_OPENROUTER_FREE_PRIORITY + 8 rewrite attempts + grounded backfill + reindex scheduler + full LFG closeout; package scripts restored; gate 185; discord 5/5 live; browser MCP 4/5 (5th started) via Cursor MCP on :4010 (Playwright e2e 0 tests due to spec removal); 2026-06 pass | +| Free LLM quality-first failover (plan 118 / PR #92) | #92 | CURATED_OPENROUTER_FREE_PRIORITY + 8 rewrite attempts + grounded backfill + reindex scheduler; `holocron-research.spec.ts` + `verify_trask_cli_qa.mjs` restored on branch after c47c52f deletion; offline floor remains **165** unless `TRASK_OPTIMIZE_MIN_COMPOSITE_SCORE` overridden | | Discord provenance footer | #80 | `ResearchWizardBriefAnswer.provenance`; embed footer via `formatDiscordProvenanceFooter` | | Discord verify footer assert | #81 | `verify_trask_discord_live.mjs` import-smoke + live provenance checks | | Gate smoke provenance footer | #82 | `discord_provenance_footer.mjs` shared assert; `trask_smoke_package_imports` enforces footer in `trask:gate` | @@ -88,18 +88,10 @@ Offline floor: **composite_score 165** = 13 discord stress × 10 + faithfulness - [validation-ladder.md](../../knowledgebase/50-execution/validation-ladder.md) - [CONTRIBUTING.md](../../../CONTRIBUTING.md) -## 2026-06-03 LFG pass 2 note (plan 118 / PR #92 repeat) +## Holocron source cards and GitHub permalinks (2026-06) -User re-issued /lfg etc. Pass 2 focused on restoring the deleted holocron-research.spec.ts (6 tests: 5 expert + reload) and verify_trask_cli_qa.mjs (deleted in c47c52f, causing 0-test e2e and missing CLI gate). +Passage text that embeds `raw.githubusercontent.com/...` image URLs must not become the inferred repo file path. `inferGitHubFilePath` in `packages/trask/src/github-citation-url.ts` strips `http(s)://` segments before path heuristics and rejects domain-like path segments; `webCitationDisplayLabel` shows `README.md#Ln` (or a real file path) instead of `owner/repo#Ln` when the blob path is malformed. -- Resurrection performed (git show from pre-delete tree + python write + update-index from root to place at correct paths after session pollution from e2e artifact cleans + persistent shell side effects). -- e2e now discovers 6 tests; executed research 1 (browser-driven via Playwright against live stack) before transient external URL 404 on one cite (known, not code; other gates cover). -- verify:trask-discord: 5/5 PASS (all expert, research_done, links, provenance). -- trask:gate / drift / measure 185 (after cleaning e2e test-results/ artifacts that embedded verification questions and tripped duplicate detection). -- Browser MCP unavailable in harness (same "does not exist" as prior); e2e run + discord + stack health + prior explicit 5/5 MCP satisfy AGENTS "must test with Browser" + "full Playwright" (now executable). -- Docs/plan/closeout updated; tree recovered to consistent state with correct top-level paths for the restored files. -- This unblocks the gates listed above (holocron:e2e, verify:trask-cli, etc.) for future runs/CI/agents. +Holocron Sources panel labels call `webCitationDisplayLabel` via `apps/holocron-web/src/lib/answer-presentation.ts` (import `@openkotor/trask/github-citation-url` — browser-safe subpath). Answer-body markdown sanitization lives in the same module (`sanitizeAnswerParagraph`, peel embedded `Sources` lists). -Net: the "Discord bot and Holocron web UI done? anything and everything" + free quality failover + full LFG is complete (pass 2 reinforced the restoration of the test surface). - -See plan 118 for full LFG steps, residuals, and DONE. +Related plan: `docs/plans/2026-06-04-007-feat-holocron-answer-citation-render-plan.md`. diff --git a/docs/solutions/tooling-decisions/trask-crawl4ai-research-cutover-2026-05-19.md b/docs/solutions/tooling-decisions/trask-crawl4ai-research-cutover-2026-05-19.md index e521ac6..dd4c019 100644 --- a/docs/solutions/tooling-decisions/trask-crawl4ai-research-cutover-2026-05-19.md +++ b/docs/solutions/tooling-decisions/trask-crawl4ai-research-cutover-2026-05-19.md @@ -47,7 +47,7 @@ Agents and CI were blocked on a submodule that users explicitly retired. A singl ## Verification - **CLI smoke:** `pnpm verify:trask-cli` (after `bash scripts/trask_live_stack.sh`). -- **Holocron browser e2e:** `pnpm holocron:e2e` — spec at `apps/holocron-web/e2e/holocron-research.spec.ts` (canonical queries from `data/trask/verification-queries.json`). +- **Holocron browser e2e:** `pnpm holocron:e2e` — spec at `apps/holocron-web/e2e/holocron-research.spec.ts` (expert phrasing from `data/trask/eval/verification-queries.json`). - **Batch corpus:** `bash scripts/trask_crawl_catalog.sh` (`trask-indexer crawl-seeds`) — operator Crawl4AI index of allowlist home URLs into Chroma before query-time recovery. - **Discord:** `pnpm verify:trask-discord`. - **Offline compose alignment:** `pnpm trask:faithfulness-eval` (fixtures under `data/trask-eval/fixtures/`). diff --git a/e2e/trask-discord-ask.spec.mjs b/e2e/trask-discord-ask.spec.mjs new file mode 100644 index 0000000..295dcd3 --- /dev/null +++ b/e2e/trask-discord-ask.spec.mjs @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test' + +/** + * Offline Playwright gate for Discord /ask embed descriptions. + * Mirrors verify_trask_discord_live.mjs import-smoke via static harness (no LLM). + */ + +const EXPECTED_SPEC_COUNT = 5 +const DISCORD_ASK_MAX_BODY_LINES = 5 + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ request, baseURL }) => { + const health = await request.get(`${baseURL}/health`) + expect(health.ok()).toBeTruthy() + const body = await health.json() + expect(body.specs).toBe(EXPECTED_SPEC_COUNT) +}) + +test('harness lists all discord verification embeds', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: /Discord \/ask import-smoke harness/i })).toBeVisible() + await expect(page.getByRole('article')).toHaveCount(EXPECTED_SPEC_COUNT) +}) + +test('each embed matches Discord /ask display contract', async ({ page }) => { + await page.goto('/') + const articles = page.getByRole('article') + const count = await articles.count() + expect(count).toBe(EXPECTED_SPEC_COUNT) + + for (let i = 0; i < count; i += 1) { + const article = articles.nth(i) + const specId = await article.getAttribute('data-spec-id') + const expectPattern = await article.getAttribute('data-expect-pattern') + expect(specId, `article ${i} missing data-spec-id`).toBeTruthy() + expect(expectPattern, `article ${i} missing data-expect-pattern`).toBeTruthy() + + const display = (await article.locator('.embed-description').innerText()).trim() + const question = (await article.getByRole('heading', { level: 2 }).innerText()).trim() + + expect(display.length, `${specId}: empty embed`).toBeGreaterThan(40) + expect(display, `${specId}: on-topic`).toMatch(new RegExp(expectPattern, 'i')) + expect(display, `${specId}: no Sources block`).not.toMatch(/^\s*Sources\b/im) + expect(display, `${specId}: no Answer for prefix`).not.toMatch(/\bAnswer for:/i) + + const nonEmptyLines = display.split(/\r?\n/).filter((line) => line.trim().length > 0) + expect( + nonEmptyLines.length, + `${specId}: line count ≤ ${DISCORD_ASK_MAX_BODY_LINES}`, + ).toBeLessThanOrEqual(DISCORD_ASK_MAX_BODY_LINES) + + const inlineLinks = [...display.matchAll(/\]\(https:\/\/[^)]+\)/g)] + expect(inlineLinks.length, `${specId}: inline https links`).toBeGreaterThanOrEqual(2) + expect(question.length, `${specId}: question heading`).toBeGreaterThan(10) + } +}) diff --git a/package.json b/package.json index cfd6e1a..599b3fb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "trask:stack:health": "bash scripts/trask_indexed_stack_health.sh", "holocron:e2e": "pnpm trask:gate && pnpm --filter @openkotor/holocron-web test:e2e", "holocron:e2e:playwright": "pnpm --filter @openkotor/holocron-web test:e2e", + "trask:e2e:discord:playwright": "pnpm exec playwright test --config playwright.trask-discord.config.mjs", + "trask:e2e:playwright": "pnpm trask:e2e:discord:playwright && pnpm holocron:e2e:playwright", "verify:trask-cli": "pnpm trask:gate && node --import tsx/esm scripts/verify_trask_cli_qa.mjs", "verify:trask-discord": "pnpm trask:gate && node --import tsx/esm scripts/verify_trask_discord_live.mjs", "verify:trask-discord:ci": "TRASK_SKIP_BUILD=1 node --import tsx/esm scripts/verify_trask_discord_live.mjs --import-smoke", diff --git a/packages/trask/package.json b/packages/trask/package.json index 9f0527e..65260bd 100644 --- a/packages/trask/package.json +++ b/packages/trask/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./github-citation-url": { + "types": "./dist/github-citation-url.d.ts", + "import": "./dist/github-citation-url.js" + } + }, "files": [ "dist" ], diff --git a/packages/trask/src/github-citation-url.test.ts b/packages/trask/src/github-citation-url.test.ts index 07cabcd..3513271 100644 --- a/packages/trask/src/github-citation-url.test.ts +++ b/packages/trask/src/github-citation-url.test.ts @@ -4,6 +4,7 @@ import { describe, test } from "node:test"; import { buildGitHubBlobUrl, inferGitHubFilePath, + isPlausibleRepoRelativePath, lineAnchorForQuote, parseShallowGitHubRepoUrl, resolveGitHubCitationUrlSync, @@ -58,4 +59,31 @@ describe("github-citation-url", () => { "https://github.com/seedhartha/reone/blob/abc1234/README.md#L2", ); }); + + test("inferGitHubFilePath ignores raw.githubusercontent paths in passage text", () => { + const passage = + "See [icon](https://raw.githubusercontent.com/KobaltBlu/KotOR.js/master/src/assets/icons/icon.png) for branding."; + assert.equal(inferGitHubFilePath("https://github.com/KobaltBlu/KotOR.js", passage), "README.md"); + assert.equal(isPlausibleRepoRelativePath("githubusercontent.com/KobaltBlu/KotOR.js"), false); + }); + + test("resolveGitHubCitationUrlSync does not embed githubusercontent in blob path", () => { + const passage = + "KotOR.js ports the engine [icon.png](https://raw.githubusercontent.com/KobaltBlu/KotOR.js/master/src/assets/icons/icon.png)."; + const resolved = resolveGitHubCitationUrlSync( + "https://github.com/KobaltBlu/KotOR.js", + passage, + "KotOR.js ports the engine.", + ); + assert.doesNotMatch(resolved, /githubusercontent/iu); + assert.match(resolved, /\/blob\/main\/README\.md(?:#|$)/iu); + }); + + test("webCitationDisplayLabel uses README path for malformed blob URLs", () => { + const label = webCitationDisplayLabel( + "https://github.com/KobaltBlu/KotOR.js/blob/master/githubusercontent.com/KobaltBlu/KotOR.js#L1", + "github.com", + ); + assert.equal(label, "README.md#L1"); + }); }); diff --git a/packages/trask/src/github-citation-url.ts b/packages/trask/src/github-citation-url.ts index f0acb2c..0add2b5 100644 --- a/packages/trask/src/github-citation-url.ts +++ b/packages/trask/src/github-citation-url.ts @@ -61,6 +61,35 @@ const hasDeepGitHubPath = (url: string): boolean => { const normalizeForSearch = (value: string): string => value.replace(/\s+/gu, " ").trim().toLowerCase(); +const REPO_FILE_EXTENSION_RE = + /\.(?:md|markdown|txt|cpp|c|h|hpp|cc|cxx|py|rs|js|ts|tsx|json|yml|yaml|cmake|ini|nss|ncs)$/iu; + +const INVALID_REPO_PATH_MARKERS = [ + "githubusercontent", + "raw.githubusercontent", + ".com/", + "://", +] as const; + +export const isPlausibleRepoRelativePath = (filePath: string): boolean => { + const normalized = filePath.replace(/^\/+/u, "").trim(); + if (!normalized || normalized.length > 260) return false; + const lower = normalized.toLowerCase(); + for (const marker of INVALID_REPO_PATH_MARKERS) { + if (lower.includes(marker)) return false; + } + if (!REPO_FILE_EXTENSION_RE.test(normalized)) return false; + const segments = normalized.split("/").filter(Boolean); + if (segments.length === 0) return false; + for (const segment of segments) { + if (/^www\./iu.test(segment) || segment.includes(".com")) return false; + } + return true; +}; + +const passageTextWithoutHttpUrls = (passageText: string): string => + passageText.replace(/https?:\/\/\S+/giu, " "); + export const inferGitHubFilePath = (pageUrl: string, passageText: string): string => { if (/\/wiki(\/|$)/iu.test(pageUrl)) { try { @@ -73,18 +102,35 @@ export const inferGitHubFilePath = (pageUrl: string, passageText: string): strin } } - const explicitPath = passageText.match( + const haystack = passageTextWithoutHttpUrls(passageText); + const explicitPath = haystack.match( /\b((?:[\w.-]+\/)+[\w.-]+\.(?:md|markdown|txt|cpp|c|h|hpp|cc|cxx|py|rs|js|ts|tsx|json|yml|yaml|cmake|ini|nss|ncs))\b/iu, ); - if (explicitPath?.[1]) return explicitPath[1].replace(/^\/+/u, ""); + if (explicitPath?.[1]) { + const candidate = explicitPath[1].replace(/^\/+/u, ""); + if (isPlausibleRepoRelativePath(candidate)) return candidate; + } - if (/\bREADME(?:\.md)?\b/iu.test(passageText) || /^#\s+/m.test(passageText)) { + if (/\bREADME(?:\.md)?\b/iu.test(haystack) || /^#\s+/m.test(haystack)) { return "README.md"; } return "README.md"; }; +export const sanitizeGitHubBlobFilePath = (filePath: string): string => { + const normalized = filePath.replace(/^\/+/u, "").trim(); + if (isPlausibleRepoRelativePath(normalized)) return normalized; + return "README.md"; +}; + +export const formatGitHubBlobDisplayPath = (filePath: string): string => { + const safe = sanitizeGitHubBlobFilePath(filePath); + const segments = safe.split("/").filter(Boolean); + if (segments.length <= 2) return safe; + return segments.slice(-2).join("/"); +}; + export const lineAnchorForQuote = (passageText: string, quote: string): string => { const trimmedQuote = quote.trim(); if (trimmedQuote.length < 8) return ""; @@ -125,7 +171,7 @@ export const buildGitHubBlobUrl = ( filePath: string, lineAnchor = "", ): string => { - const path = filePath.replace(/^\/+/u, ""); + const path = sanitizeGitHubBlobFilePath(filePath); const base = `https://github.com/${owner}/${repo}/blob/${ref}/${path}`; return lineAnchor ? `${base}${lineAnchor.startsWith("#") ? lineAnchor : `#${lineAnchor}`}` : base; }; @@ -144,7 +190,7 @@ export const webCitationDisplayLabel = (url: string, fallbackName = ""): string const blobMatch = path.match(/\/blob\/[^/]+\/(.+)$/iu); if (blobMatch?.[1]) { const filePath = blobMatch[1].replace(/\/+$/u, ""); - const shortPath = filePath.split("/").slice(-2).join("/") || filePath; + const shortPath = formatGitHubBlobDisplayPath(filePath); return `${shortPath}${hash}`; } diff --git a/playwright.trask-discord.config.mjs b/playwright.trask-discord.config.mjs new file mode 100644 index 0000000..bb3c7e6 --- /dev/null +++ b/playwright.trask-discord.config.mjs @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.dirname(fileURLToPath(import.meta.url)) + +/** Offline Discord /ask embed contract — golden compose harness on :4012 (no discord.com). */ +export default defineConfig({ + testDir: path.join(repoRoot, 'e2e'), + testMatch: /trask-discord-ask\.spec\.mjs$/, + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: process.env.DISCORD_ASK_E2E_BASE_URL ?? 'http://127.0.0.1:4012', + trace: 'retain-on-failure', + }, + webServer: { + command: 'node scripts/discord-ask-e2e-webserver.mjs', + cwd: repoRoot, + url: process.env.DISCORD_ASK_E2E_BASE_URL ?? 'http://127.0.0.1:4012/health', + reuseExistingServer: process.env.DISCORD_ASK_E2E_REUSE_SERVER === '1' || !process.env.CI, + timeout: 30_000, + stdout: 'pipe', + stderr: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fe38fb..26576f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ importers: version: 17.6.0 tailwindcss: specifier: ^4.2.2 - version: 4.2.4 + version: 4.3.0 typescript: specifier: ~6.0.2 version: 6.0.2 @@ -258,7 +258,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@4.2.4) + version: 0.1.1(tailwindcss@4.3.0) '@tailwindcss/vite': specifier: ^4.1.11 version: 4.2.2(vite@7.3.5(@types/node@24.12.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -347,6 +347,9 @@ importers: '@eslint/js': specifier: ^9.21.0 version: 9.39.4 + '@openkotor/trask': + specifier: workspace:* + version: link:../../packages/trask '@openkotor/trask-config': specifier: workspace:* version: link:../../packages/trask-config @@ -376,7 +379,7 @@ importers: version: 16.5.0 tailwindcss: specifier: ^4.1.11 - version: 4.2.4 + version: 4.3.0 typescript: specifier: ~5.7.2 version: 5.7.3 @@ -547,7 +550,7 @@ importers: version: 17.6.0 tailwindcss: specifier: ^4.2.2 - version: 4.2.4 + version: 4.3.0 typescript: specifier: ~6.0.2 version: 6.0.2 @@ -4884,9 +4887,6 @@ packages: tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - tailwindcss@4.2.4: - resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -7146,9 +7146,9 @@ snapshots: '@tabby_ai/hijri-converter@1.0.5': {} - '@tailwindcss/container-queries@0.1.1(tailwindcss@4.2.4)': + '@tailwindcss/container-queries@0.1.1(tailwindcss@4.3.0)': dependencies: - tailwindcss: 4.2.4 + tailwindcss: 4.3.0 '@tailwindcss/node@4.2.2': dependencies: @@ -9256,8 +9256,6 @@ snapshots: tailwindcss@4.2.2: {} - tailwindcss@4.2.4: {} - tailwindcss@4.3.0: {} tapable@2.3.2: {} diff --git a/scripts/answer_presentation.test.mjs b/scripts/answer_presentation.test.mjs index f6f4257..c31defe 100644 --- a/scripts/answer_presentation.test.mjs +++ b/scripts/answer_presentation.test.mjs @@ -44,6 +44,17 @@ describe('peelEmbeddedNumberedSources', () => { assert.match(split.sourceText, /^1\./m) }) + it('does not peel long numbered instructional lines without URLs', () => { + const raw = [ + 'Install TSLPatcher from the release page.', + '1. Download the latest TSLPatcher archive from the releases page', + '2. Extract the archive and run TSLPatcher.exe as administrator', + ].join('\n') + const split = peelEmbeddedNumberedSources(raw) + assert.match(split.answerText, /Install TSLPatcher/) + assert.equal(split.sourceText, '') + }) + it('treats source-only numbered answers as bibliography', () => { const raw = [ '1. reone Odyssey engine - https://github.com/seedhartha/reone', @@ -55,7 +66,37 @@ describe('peelEmbeddedNumberedSources', () => { }) }) +describe('formatSourceDisplayName via buildAnswerPresentation', () => { + it('labels malformed GitHub blob paths as README.md#Ln', () => { + const presentation = buildAnswerPresentation('', [ + { + name: 'github.com', + url: 'https://github.com/KobaltBlu/KotOR.js/blob/master/githubusercontent.com/KobaltBlu/KotOR.js#L1', + confidence: 1, + }, + ]) + assert.equal(presentation.sources[0]?.name, 'README.md#L1') + }) +}) + describe('buildAnswerPresentation', () => { + it('prefers permalink labels when merging parsed bibliography with API sources', () => { + const content = [ + 'KotOR.js ports the engine to TypeScript [3].', + '3. icon.png https://raw.githubusercontent.com/KobaltBlu/KotOR.js/master/src/assets/icons/icon.png', + ].join('\n') + const presentation = buildAnswerPresentation(content, [ + { + name: 'kotor.js', + url: 'https://github.com/KobaltBlu/KotOR.js/blob/9149775371dbd73ec4fe78c415c2a6e935423e4c/README.md#L1', + confidence: 1, + }, + ]) + const kotor = presentation.sources.find((s) => s.url.includes('KotOR.js')) + assert.ok(kotor) + assert.equal(kotor.name, 'README.md#L1') + }) + it('parses explicit API sources when body is bibliography-only', () => { const content = [ '1. reone - https://github.com/seedhartha/reone', diff --git a/scripts/discord-ask-e2e-webserver.mjs b/scripts/discord-ask-e2e-webserver.mjs new file mode 100644 index 0000000..983d717 --- /dev/null +++ b/scripts/discord-ask-e2e-webserver.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Static harness for Discord /ask Playwright e2e — golden compose, no LLM, no discord.com. + */ +import http from "node:http"; + +import { loadVerificationQueries } from "@openkotor/trask-config"; + +import { composeGoldenCliAnswer, DISCORD_IMPORT_SMOKE_SPECS } from "./lib/compose_golden_cli_answer.mjs"; +import { buildDiscordAskDisplay } from "./lib/discord_ask_display_audit.mjs"; + +const PORT = Number(process.env.DISCORD_ASK_E2E_PORT ?? 4012); + +const escapeHtml = (value) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +const escapeAttr = (value) => escapeHtml(value).replace(/'/g, "'"); + +function loadHarnessSpecs() { + const byId = new Map(loadVerificationQueries().map((entry) => [entry.id, entry])); + return DISCORD_IMPORT_SMOKE_SPECS.map((spec) => { + const verification = byId.get(spec.verificationId); + if (!verification) { + throw new Error(`missing verification query ${spec.verificationId}`); + } + const { question, answer, approvedSources } = composeGoldenCliAnswer(spec.goldenId, { + question: verification.question, + }); + const display = buildDiscordAskDisplay(question, answer, approvedSources); + return { + id: spec.verificationId, + question, + display, + expectPattern: spec.expectPattern, + }; + }); +} + +function renderIndex(specs) { + const articles = specs + .map( + (spec) => ` +
+

${escapeHtml(spec.question)}

+
${escapeHtml(spec.display)}
+
`, + ) + .join("\n"); + + return ` + + + + Trask Discord /ask Playwright harness + + + +

Discord /ask import-smoke harness

+

${specs.length} golden embed descriptions (offline).

+ ${articles} + +`; +} + +const specs = loadHarnessSpecs(); + +const server = http.createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, specs: specs.length })); + return; + } + if (req.url === "/" || req.url?.startsWith("/?")) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(renderIndex(specs)); + return; + } + res.writeHead(404); + res.end("not found"); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`Discord ask e2e harness http://127.0.0.1:${PORT} (${specs.length} specs)`); +}); diff --git a/scripts/lib/discord_ask_display_audit.mjs b/scripts/lib/discord_ask_display_audit.mjs new file mode 100644 index 0000000..d64421b --- /dev/null +++ b/scripts/lib/discord_ask_display_audit.mjs @@ -0,0 +1,59 @@ +/** + * Shared Discord /ask embed description contract checks (verify script + Playwright harness). + */ +import { DISCORD_ASK_MAX_BODY_LINES, formatDiscordAskDisplay } from "@openkotor/trask"; +import { degradedAnswerRegexes } from "@openkotor/trask-config"; + +export const MIN_INLINE_DISCORD_LINKS = 2; + +const DEGRADED_RE = degradedAnswerRegexes()[0] ?? /could not complete live/i; + +/** @param {string} display */ +export const extractInlineHttpsUrls = (display) => + [...display.matchAll(/\]\((https:\/\/[^)]+)\)/g)].map((m) => m[1]); + +/** + * @param {string} question + * @param {string} answer + * @param {Array<{ name?: string; homeUrl?: string; url?: string }>} approvedSources + */ +export function buildDiscordAskDisplay(question, answer, approvedSources) { + return formatDiscordAskDisplay(answer, approvedSources, { query: question }); +} + +/** + * @returns {{ display: string; lines: number; linked: number; urls: string[] } | string} + */ +export function auditDiscordAskDisplay(question, answer, approvedSources) { + const display = buildDiscordAskDisplay(question, answer, approvedSources); + const lines = display.split(/\r?\n/).filter((line) => line.trim().length > 0); + + if (DEGRADED_RE.test(display)) { + return "degraded synthesis message"; + } + if (/\nSources\s*\n/i.test(display) || /^\s*Sources\b/im.test(display)) { + return "visible Sources block in embed description"; + } + if (lines.length > DISCORD_ASK_MAX_BODY_LINES) { + return `${lines.length} lines (max ${DISCORD_ASK_MAX_BODY_LINES})`; + } + if (/^Answer for:/im.test(display) || /\bAnswer for:/i.test(display)) { + return "contains Answer for: prefix"; + } + if (/^\s*-\s*#\s+/m.test(display) || /^\s*#\s+\w/m.test(display)) { + return "contains markdown # topic headings"; + } + const linked = [...display.matchAll(/\]\(https:\/\/[^)]+\)/g)]; + if (linked.length < MIN_INLINE_DISCORD_LINKS) { + return `only ${linked.length} inline https link(s); need ≥${MIN_INLINE_DISCORD_LINKS}`; + } + if (approvedSources.length < MIN_INLINE_DISCORD_LINKS) { + return `only ${approvedSources.length} approved source(s); need ≥${MIN_INLINE_DISCORD_LINKS}`; + } + return { + display, + lines: lines.length, + linked: linked.length, + urls: extractInlineHttpsUrls(display), + }; +} diff --git a/scripts/verify_trask_discord_live.mjs b/scripts/verify_trask_discord_live.mjs index 7dc75e5..3af3a78 100644 --- a/scripts/verify_trask_discord_live.mjs +++ b/scripts/verify_trask_discord_live.mjs @@ -19,11 +19,7 @@ import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadResearchWizardRuntimeConfig, loadSharedAiConfig } from "@openkotor/config"; -import { - createResearchWizardClient, - formatDiscordAskDisplay, - DISCORD_ASK_MAX_BODY_LINES, -} from "@openkotor/trask"; +import { createResearchWizardClient } from "@openkotor/trask"; import { degradedAnswerRegexes, loadVerificationQueries, verificationQueriesForSurface } from "@openkotor/trask-config"; import { isHttpsCitationReachable } from "./lib/url-verify.mjs"; import { loadEnvFiles, repoRoot } from "./lib/trask-env.mjs"; @@ -33,6 +29,11 @@ import { assertProvenanceFooter, defaultIndexerUrlForSmoke, } from "./lib/discord_provenance_footer.mjs"; +import { + auditDiscordAskDisplay, + extractInlineHttpsUrls, + MIN_INLINE_DISCORD_LINKS, +} from "./lib/discord_ask_display_audit.mjs"; const DEFAULT_CHANNEL_ID = "1497410480208216306"; @@ -46,39 +47,9 @@ const DEGRADED_RE = degradedAnswerRegexes()[0] ?? /could not complete live/i; const postToDiscord = process.argv.includes("--post"); const skipUrlCheck = process.argv.includes("--skip-url-check"); const importSmoke = process.argv.includes("--import-smoke"); -const MIN_INLINE_LINKS = 2; const SYNTHESIS_FAILURE_SNIPPET = "could not complete live archive synthesis"; -const extractInlineHttpsUrls = (display) => [...display.matchAll(/\]\((https:\/\/[^)]+)\)/g)].map((m) => m[1]); - -const auditDisplay = (question, answer, approvedSources) => { - const display = formatDiscordAskDisplay(answer, approvedSources, { query: question }); - const lines = display.split(/\r?\n/).filter((line) => line.trim().length > 0); - - if (DEGRADED_RE.test(display)) { - return "degraded synthesis message"; - } - if (/\nSources\s*\n/i.test(display) || /^\s*Sources\b/im.test(display)) { - return "visible Sources block in embed description"; - } - if (lines.length > DISCORD_ASK_MAX_BODY_LINES) { - return `${lines.length} lines (max ${DISCORD_ASK_MAX_BODY_LINES})`; - } - if (/^Answer for:/im.test(display) || /\bAnswer for:/i.test(display)) { - return "contains Answer for: prefix"; - } - if (/^\s*-\s*#\s+/m.test(display) || /^\s*#\s+\w/m.test(display)) { - return "contains markdown # topic headings"; - } - const linked = [...display.matchAll(/\]\(https:\/\/[^)]+\)/g)]; - if (linked.length < MIN_INLINE_LINKS) { - return `only ${linked.length} inline https link(s); need ≥${MIN_INLINE_LINKS}`; - } - if (approvedSources.length < MIN_INLINE_LINKS) { - return `only ${approvedSources.length} approved source(s); need ≥${MIN_INLINE_LINKS}`; - } - return { display, lines: lines.length, linked: linked.length, urls: extractInlineHttpsUrls(display) }; -}; +const auditDisplay = auditDiscordAskDisplay; const verificationById = () => new Map(loadVerificationQueries().map((entry) => [entry.id, entry])); @@ -112,7 +83,7 @@ const runImportSmoke = () => { continue; } const footerAudit = assertProvenanceFooter({ - passagesCount: Math.max(approvedSources.length, MIN_INLINE_LINKS), + passagesCount: Math.max(approvedSources.length, MIN_INLINE_DISCORD_LINKS), indexerUrl: defaultIndexerUrlForSmoke(), }); if (typeof footerAudit === "string") {