Codex/chat topic scope trust tier - #2
Conversation
📝 WalkthroughWalkthroughThe PR adds deterministic topic scoping for chat, trusted search validation, official corpus draft import, topic-aware candidate handling, guarded admin workflows, and updated UI, tests, and documentation. ChangesChat scope and trusted answers
Official corpus import
Admin and product scope
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Client
participant ChatRoute
participant TopicClassifier
participant ManagedAnswers
participant WebSearch
participant CandidateStore
Client->>ChatRoute: Submit question
ChatRoute->>TopicClassifier: Classify topic
TopicClassifier-->>ChatRoute: Scope decision
ChatRoute->>ManagedAnswers: Query topic-matched answer
ManagedAnswers-->>ChatRoute: Answer or no match
ChatRoute->>WebSearch: Request validated result
WebSearch-->>ChatRoute: Provider result
ChatRoute->>CandidateStore: Persist eligible official result
CandidateStore-->>ChatRoute: Persistence result
ChatRoute-->>Client: Render scoped answer
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/admin/AdminDashboard.tsx`:
- Around line 431-440: Add type="button" to every button rendered in the
admin-actions block of the candidate card, including the Biên tập, Gửi duyệt,
Duyệt & đưa vào RAG, Từ chối, and Lưu trữ buttons, while preserving their
existing handlers and conditions.
In `@app/admin/api/web-search-candidates/route.ts`:
- Around line 87-116: The intakeDraft handling in the event-processing loop must
validate metadata.intakeDraft against the CandidateSnapshot draft shape,
including required citations and tags arrays and their expected field types,
before assigning it to intakeDraft or deriving intakeTitle and intakeAnswer.
Leave intakeDraft null for malformed drafts; the existing startEdit usage in
app/admin/AdminDashboard.tsx at line 309 requires no direct change.
- Around line 188-214: The PATCH mutation guard currently scans only the top-200
events from listWebSearchCandidates, allowing out-of-window ineligible
candidates through. Replace the eligibility lookup around publicationBlocked
with a targeted query against web_search_candidate_events filtered by
candidate_id, and preserve the metadata parsing and 409 response when
publicationEligible is false before save_revision or workflow transitions.
In `@docs/PRODUCT_REQUIREMENTS.md`:
- Around line 26-29: Update the MVP scope message in PRODUCT_REQUIREMENTS.md to
replace the outdated “mạng xã hội” and “bản quyền” terms with the renamed topics
“An toàn và ứng xử trên mạng” and “Bản quyền học đường,” matching Section 6,
FR-04, and the chat-panel wording.
In `@lib/official-corpus-drafts.ts`:
- Around line 833-849: Update the binding-upgrade insert within the
missingSourceIndexes mapping to apply the same isOfficialGovernmentHost
validation used by the new-record path before writing
web_search_candidate_sources.official_host. Preserve the existing insert
behavior for official hosts and reject or handle non-official hosts consistently
with the new-record flow.
- Around line 442-448: Scope the superseded-content guard around the
JSON.stringify regex check to the affected intent, using the existing
online_safety.false_or_insulting_content rule or the topic === "online_safety"
condition. Preserve the current null return and matching patterns, while
allowing unrelated records with these phrases to remain valid.
- Around line 743-774: Update the legacy binding path around `legacyMatches` and
`bindingUpgrades` to compare the stored candidate answer with
`record.proposedSnapshot.answer`; return `{ ok: false, reason: "conflict" }`
when they differ, otherwise update `web_search_candidates.initial_answer_text`
and `content_sha256` to the current answer and hash before binding missing
sources. Preserve the existing source-binding and event behavior.
- Around line 49-78: Document the provenance of each entry in
legacyContentHashes by adding comments that identify the canonical input
legacyCanonical(current.initial_answer_text, current.provider_model,
existingSources.map(source => source.official_url)) and the legacy packet
version/date that produced its digest. Keep the existing UUID-to-digest mappings
unchanged and associate each provenance note with the corresponding entry.
- Around line 566-568: Update eventId and both call sites near the event
creation flows to derive IDs using the prefix plus the full candidateId, without
removing its first character. Verify the id column supports the resulting length
and add a migration for rows generated by the current truncated scheme.
In `@lib/web-search-candidates.ts`:
- Around line 78-93: Update subtypeSql used by findReviewedWebCandidate to apply
the same Vietnamese diacritic-stripping and case-normalization as
reviewedCandidateMatchesChatTopic, or remove the SQL tag filter so the JS check
handles matching consistently. Add a positive findReviewedWebCandidate test with
a stored candidate tagged using a copyright variant such as BẢN QUYỀN, verifying
it is returned.
In `@package.json`:
- Line 28: Raise the package.json engines.node minimum from 22.13.0 to at least
22.15.0 so the corpus:import:local script using module.registerHooks() runs on
every supported Node.js version.
In `@scripts/import-official-corpus-drafts.mjs`:
- Around line 24-61: Extract PreparedStatement, LocalD1Adapter, and the
duplicated registerHooks resolve hook into a shared module such as
lib/local-d1-adapter.mjs; update scripts/import-official-corpus-drafts.mjs lines
24-61 to import and use it. In tests/official-corpus-drafts.test.mjs lines
55-94, delete the local Prepared and D1Adapter implementations and import the
shared adapter so both consumers independently preserve the D1Like contract and
success flag.
In `@tests/chat-topic-scope.test.mjs`:
- Around line 245-283: Update every provider test identified in this file,
including the test containing `official-looking output with the wrong source
kind is never persisted`, to register worker-environment cleanup through the
test context’s failure-safe cleanup hook. Move each
`globalThis.__topicScopeWorkerEnv` key deletion into that hook and remove the
corresponding trailing delete loop from the test body, preserving the existing
test setup and assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2913a81d-47e6-4bf4-b381-2e6ec4f078e5
📒 Files selected for processing (23)
.env.exampleapp/admin/AdminDashboard.tsxapp/admin/api/web-search-candidates/route.tsapp/api/chat/route.tsapp/page.tsxdocs/PRODUCT_REQUIREMENTS.mddocs/PROGRESS.mddocs/TECHNICAL_SPEC.mddocs/USER_STORIES.mdfixtures/rag/official-corpus-drafts.v1.jsonlib/chat-answer-presentation.tslib/chat-topic-scope.tslib/legal-chat.tslib/official-corpus-drafts.tslib/openai-web-search.tslib/web-search-candidates.tspackage.jsonscripts/import-official-corpus-drafts.mjstests/chat-topic-scope.test.mjstests/official-corpus-drafts.test.mjstests/openai-web-search.test.mjstests/rendered-html.test.mjstests/web-search-candidates.test.mjs
| <h3>{candidate.snapshot?.title || candidate.intakeTitle || "Bản nháp chưa được biên tập"}</h3> | ||
| <p>{candidate.snapshot?.answer || candidate.intakeAnswer || candidate.initialAnswer}</p> | ||
| {!candidate.publicationEligible && <p><strong>Hướng dẫn an toàn MVP:</strong> nội dung này không đi vào kho căn cứ pháp lý.</p>} | ||
| {candidate.reviewReason && <p><strong>Lý do từ chối:</strong> {candidate.reviewReason}</p>} | ||
| <div className="admin-actions"> | ||
| {canEdit && (candidate.status === "draft" || candidate.status === "rejected") && <button onClick={() => startEdit(candidate)}>Biên tập</button>} | ||
| {canEdit && candidate.status === "draft" && candidate.snapshot && candidate.editorPrincipalId === principalId && <button onClick={() => void action(candidate, "submit")}>Gửi duyệt</button>} | ||
| {canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button onClick={() => void action(candidate, "approve")}>Duyệt & đưa vào RAG</button>} | ||
| {canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button className="danger" onClick={() => void action(candidate, "reject")}>Từ chối</button>} | ||
| {canReview && candidate.status === "published" && <button onClick={() => void action(candidate, "archive")}>Lưu trữ</button>} | ||
| {candidate.publicationEligible && canEdit && (candidate.status === "draft" || candidate.status === "rejected") && <button onClick={() => startEdit(candidate)}>Biên tập</button>} | ||
| {candidate.publicationEligible && canEdit && candidate.status === "draft" && candidate.snapshot && candidate.editorPrincipalId === principalId && <button onClick={() => void action(candidate, "submit")}>Gửi duyệt</button>} | ||
| {candidate.publicationEligible && canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button onClick={() => void action(candidate, "approve")}>Duyệt & đưa vào RAG</button>} | ||
| {candidate.publicationEligible && canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button className="danger" onClick={() => void action(candidate, "reject")}>Từ chối</button>} | ||
| {candidate.publicationEligible && canReview && candidate.status === "published" && <button onClick={() => void action(candidate, "archive")}>Lưu trữ</button>} |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Static analysis flags the buttons at Lines 436-440 for missing an explicit type attribute. These cards render as siblings of the editor <form>, not inside it, so there is no live risk of accidental form submission here. Setting type="button" explicitly is still good defensive practice in case the markup is restructured later.
🧰 Tools
🪛 React Doctor (0.9.1)
[warning] 436-436: Your users can submit the form by accident because a <button> with no type defaults to submit.
Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".
(button-has-type)
[warning] 437-437: Your users can submit the form by accident because a <button> with no type defaults to submit.
Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".
(button-has-type)
[warning] 438-438: Your users can submit the form by accident because a <button> with no type defaults to submit.
Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".
(button-has-type)
[warning] 439-439: Your users can submit the form by accident because a <button> with no type defaults to submit.
Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".
(button-has-type)
[warning] 440-440: Your users can submit the form by accident because a <button> with no type defaults to submit.
Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".
(button-has-type)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/admin/AdminDashboard.tsx` around lines 431 - 440, Add type="button" to
every button rendered in the admin-actions block of the candidate card,
including the Biên tập, Gửi duyệt, Duyệt & đưa vào RAG, Từ chối, and Lưu trữ
buttons, while preserving their existing handlers and conditions.
Source: Linters/SAST tools
| let intakeDraft: unknown = null; | ||
| let publicationEligible = true; | ||
| let intakeTitle = ""; | ||
| let intakeAnswer = ""; | ||
| for (const event of eventsByCandidate.get(id) ?? []) { | ||
| if (typeof event.metadata_json !== "string") continue; | ||
| try { | ||
| const metadata = JSON.parse(event.metadata_json) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| if ( | ||
| metadata.intakeDraft && | ||
| typeof metadata.intakeDraft === "object" && | ||
| !Array.isArray(metadata.intakeDraft) | ||
| ) { | ||
| intakeDraft = metadata.intakeDraft; | ||
| const draft = metadata.intakeDraft as Record<string, unknown>; | ||
| intakeTitle = | ||
| typeof draft.title === "string" ? draft.title : intakeTitle; | ||
| intakeAnswer = | ||
| typeof draft.answer === "string" ? draft.answer : intakeAnswer; | ||
| } | ||
| if (metadata.publicationEligible === false) { | ||
| publicationEligible = false; | ||
| } | ||
| } catch { | ||
| // Invalid historical metadata is ignored by this read-only projection. | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)app/admin/(api/web-search-candidates/route\.ts|AdminDashboard\.tsx)$|CandidateSnapshot|draftSnapshot|intakeDraft|normalizeReviewedCandidateSnapshot' || true
echo
echo "== route ts outline =="
ast-grep outline app/admin/api/web-search-candidates/route.ts --view expanded || true
echo
echo "== AdminDashboard outline relevant =="
ast-grep outline app/admin/AdminDashboard.tsx --view expanded || true
echo
echo "== relevant snippets =="
sed -n '1,180p' app/admin/api/web-search-candidates/route.ts
echo
sed -n '280,330p' app/admin/AdminDashboard.tsxRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 8637
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== occurrences =="
rg -n "draftSnapshot|intakeDraft|normalizeReviewedCandidateSnapshot|CandidateSnapshot|tags\.join|citations\.map|startEdit" app/admin -S || true
echo
echo "== focused read with line numbers =="
echo "-- web-search-candidates route 1-180 --"
cat -n -v app/admin/api/web-search-candidates/route.ts | sed -n '1,180p'
echo
echo "-- AdminDashboard 1,280-340 --"
cat -n -v app/admin/AdminDashboard.tsx | sed -n '1,40p;260,350p'Repository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 16682
Validate intakeDraft before returning it as draftSnapshot.
metadata.intakeDraft is assigned after only checking that it is a non-array object, then exposed as CandidateSnapshot. Malformed drafts can miss citations/tags or contain non-array fields, and startEdit later uses the draft directly in the editor render path. Validate against the snapshot shape before assigning it to intakeDraft; otherwise keep draftSnapshot as null.
📍 Affects 2 files
app/admin/api/web-search-candidates/route.ts#L87-L116(this comment)app/admin/AdminDashboard.tsx#L309-L309
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/admin/api/web-search-candidates/route.ts` around lines 87 - 116, The
intakeDraft handling in the event-processing loop must validate
metadata.intakeDraft against the CandidateSnapshot draft shape, including
required citations and tags arrays and their expected field types, before
assigning it to intakeDraft or deriving intakeTitle and intakeAnswer. Leave
intakeDraft null for malformed drafts; the existing startEdit usage in
app/admin/AdminDashboard.tsx at line 309 requires no direct change.
| const workflow = await listWebSearchCandidates(); | ||
| const publicationBlocked = workflow.events.some((event) => { | ||
| if ( | ||
| event.candidate_id !== candidateId || | ||
| typeof event.metadata_json !== "string" | ||
| ) { | ||
| return false; | ||
| } | ||
| try { | ||
| const metadata = JSON.parse(event.metadata_json) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| return metadata.publicationEligible === false; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }); | ||
| if (publicationBlocked) { | ||
| return noStoreJson( | ||
| { | ||
| error: | ||
| "Đây là hướng dẫn an toàn cho MVP, không được đưa vào kho căn cứ pháp lý.", | ||
| }, | ||
| 409, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg 'app/admin/api/web-search-candidates/route\.ts|PRODUCT_REQUIREMENTS\.md|web_search_candidate_events|web_search_candidates' || true
echo "== route outline/size =="
wc -l app/admin/api/web-search-candidates/route.ts 2>/dev/null || true
ast-grep outline app/admin/api/web-search-candidates/route.ts 2>/dev/null | head -120 || true
echo "== relevant route lines =="
sed -n '1,260p' app/admin/api/web-search-candidates/route.ts
echo "== search listWebSearchCandidates == "
rg -n "function listWebSearchCandidates|const listWebSearchCandidates|listWebSearchCandidates|web_search_candidate_events|publicationEligible" -S .Repository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 17170
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== lib/web-search-candidates.ts relevant section =="
sed -n '630,720p' lib/web-search-candidates.ts
echo "== tests/listWebSearchCandidates section =="
sed -n '310,350p' tests/web-search-candidates.test.mjs
echo "== docs/product requirements relevant section =="
sed -n '770,810p' docs/PRODUCT_REQUIREMENTS.md
sed -n '2355,2390p' docs/TECHNICAL_SPEC.md
echo "== targeted query evidence from implementation =="
rg -n "publicationEligible|web_search_candidate_events|LIST|listWebSearchCandidates|migrations|schema" db lib tests docs -SRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 26106
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== behavioral probe: bounded query misses candidate outside top-200 =="
python3 - <<'PY'
from dataclasses import dataclass
from typing import Any
`@dataclass`
class Candidate:
id: str
updated_at: int
lifecycle_status: str = "draft"
`@dataclass`
class Event:
candidate_id: str
metadata_json: str
def list_web_search_candidates(candidates: list[Candidate], events: list[Event]):
# Mirrors lib/web-search-candidates.ts: top-200 candidates and candidate_id IN that set.
top = sorted(candidates, key=lambda c: (-c.updated_at, c.id))[:200]
top_ids = {c.id for c in top}
return [
{"candidates": top},
{"sources": []},
{"revisions": []},
{
"events": sorted(
[e for e in events if e.candidate_id in top_ids],
key=lambda e: (
e.candidate_id,
-len(e.metadata_json), # placeholder for occurred_at if metadata was JSON
e.candidate_id
)
)
},
]
def publication_blocked(listed, candidate_id: str):
# Mirrors app/admin/api/web-search-candidates/route.ts PATCH check.
for event in listed[3]["events"]:
if event.candidate_id != candidate_id:
continue
try:
metadata = __import__("json").loads(event.metadata_json)
if metadata.get("publicationEligible") is False:
return True
except Exception:
return False
return False
candidates: list[Candidate] = [
Candidate("older", 1),
Candidate("target", 0),
]
events: list[Event] = [
Event("target", '{"publicationEligible": false}''),
]
listed = list_web_search_candidates(candidates, events)
print("listed_candidate_count=" + str(len(listed[0]["candidates"])))
print("listed_events_count=" + str(len(listed[3]["events"])))
print("publication_blocked=" + str(publication_blocked(listed, "target")))
PY
echo "== schema table for web_search_candidates =="
sed -n '760,810p' db/schema.tsRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 435
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== behavioral probe: bounded query misses target candidate outside top-200 =="
python3 - <<'PY'
import json
candidates = [
["older", "2026-08-01T00:00:00Z"],
["target", "2025-01-01T00:00:00Z"],
]
events = [
["target", json.dumps({"publicationEligible": False})],
]
top = sorted(candidates, key=lambda c: (- (c[1] > "1970-01-01"), c[0]))[:200]
top_ids = {c[0] for c in top}
listed_events = [e for e in events if e[0] in top_ids]
publication_blocked = any(
e[0] == "target" and json.loads(e[1])["publicationEligible"] is False
for e in listed_events
)
print(f"listed_candidates={len(top)}")
print(f"listed_events_for_target={1 if 'target' in top_ids else 0}")
print(f"metadata_is_visibility_flag_on_target_event={'publicationEligible' in json.loads(next(e[1] for e in events if e[0] == 'target'))}")
print(f"publication_blocked_from_list_when_target_outside_top_200={publication_blocked}")
PY
echo "== schema table for web_search_candidates =="
sed -n '760,810p' db/schema.tsRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 2208
Guard every target candidate before allowing admin mutations.
PATCH uses listWebSearchCandidates() whose events rowset only contains candidates in the top-200 list, while web_search_candidate_events stores all workflow events. If candidateId is outside that window, the eligibility scan finds no event and proceeds to save_revision/workflow transition even when metadata contains publicationEligible: false, violating DEC-015. Use a targeted query for candidate_id = ? before allowing the mutation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/admin/api/web-search-candidates/route.ts` around lines 188 - 214, The
PATCH mutation guard currently scans only the top-200 events from
listWebSearchCandidates, allowing out-of-window ineligible candidates through.
Replace the eligibility lookup around publicationBlocked with a targeted query
against web_search_candidate_events filtered by candidate_id, and preserve the
metadata parsing and 409 response when publicationEligible is false before
save_revision or workflow transitions.
| Thông điệp phạm vi MVP dành cho người dùng: | ||
| “Tra cứu nhanh các quy định gần gũi với trường học — từ giao thông, mạng xã hội | ||
| đến bản quyền”. | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the MVP scope message wording with the renamed topics.
Line 26-29 says Thông điệp phạm vi MVP dành cho người dùng: "Tra cứu nhanh các quy định gần gũi với trường học — từ giao thông, mạng xã hội đến bản quyền".
This uses "mạng xã hội" and "bản quyền," the older topic terms. Elsewhere in the same PR, Section 6 renames these topics: An toàn và ứng xử trên mạng. and Bản quyền học đường. FR-04 uses the same renamed terms, and the chat-panel copy in app/page.tsx (also updated in this PR) says "ứng xử trên mạng" and "bản quyền học đường." Update Line 26-29 to use the same renamed topic terms so the product scope message stays consistent across the document and the UI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/PRODUCT_REQUIREMENTS.md` around lines 26 - 29, Update the MVP scope
message in PRODUCT_REQUIREMENTS.md to replace the outdated “mạng xã hội” and
“bản quyền” terms with the renamed topics “An toàn và ứng xử trên mạng” and “Bản
quyền học đường,” matching Section 6, FR-04, and the chat-panel wording.
| const legacyContentHashes = new Map([ | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c001", | ||
| "e14003a28151b343a78f2febf5c44773f3098818fdcf78b78dfd4acacb036a46", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c002", | ||
| "f83116bbc92565c783138c91e787dc9395690b8d22e49f53d4253321da641a15", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c003", | ||
| "24aedfabe5f144be50f4c93d0e1f5d779cb24962bf35b143ebb6ed37539066e6", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c004", | ||
| "e86c4347d4b213247bf52a9e2a16c929c26c076fe300f81b885b5243f66b005d", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c005", | ||
| "748358b54a5449ffa4e7073875923d5db339ee2bf12a157ef13a679946ec3632", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c006", | ||
| "00556f00cd3631d1cb909338736822c6edf4570dab2abb86f962f903f370efff", | ||
| ], | ||
| [ | ||
| "a9d6f3e2-7331-4cb0-8db5-0d745b06c007", | ||
| "1569e7871065269708d54e215586e3f74c2bb224eda38d3cb27a8bf1401f7674", | ||
| ], | ||
| ]); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -t f 'official-corpus-drafts.v1.json' -x node -e '
const fs = require("node:fs");
const packet = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const ids = packet.records.map((record) => record.candidateId).sort();
console.log("fixture candidateIds:", ids);
' {}
rg -n 'a9d6f3e2-7331-4cb0-8db5-0d745b06c00' lib/official-corpus-drafts.tsRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 826
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== file outline =="
ast-grep outline lib/official-corpus-drafts.ts --view expanded || true
echo "== relevant file section =="
cat -n lib/official-corpus-drafts.ts | sed -n '1,140p'
echo "== search legacyIdentityMatches and legacyContentHashes =="
rg -n "legacyContentHashes|legacyIdentityMatches|legacyCanonical|candidateId|initial_answer_text" lib tests spec . --glob '!node_modules' --glob '!dist' --glob '!build' | head -n 200Repository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 21865
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== legacyCanonical and identity branch =="
cat -n lib/official-corpus-drafts.ts | sed -n '540,820p'
echo "== relevant official-corpus tests =="
cat -n tests/official-corpus-drafts.test.mjs | sed -n '430,535p'
echo "== fixtures version/header =="
cat -n fixtures/rag/official-corpus-drafts.v1.json | sed -n '1,50p'Repository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 18578
Document the provenance of legacyContentHashes.
The map gates legacyIdentityMatches, so wrong entries can turn an upgrade into conflict. Add a comment that names the canonical input (legacyCanonical(current.initial_answer_text, current.provider_model, existingSources.map(source => source.official_url))) and the legacy packet version/date that produced each digest.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/official-corpus-drafts.ts` around lines 49 - 78, Document the provenance
of each entry in legacyContentHashes by adding comments that identify the
canonical input legacyCanonical(current.initial_answer_text,
current.provider_model, existingSources.map(source => source.official_url)) and
the legacy packet version/date that produced its digest. Keep the existing
UUID-to-digest mappings unchanged and associate each provenance note with the
corresponding entry.
| ...missingSourceIndexes.map((sourceIndex, offset) => { | ||
| const citation = record.proposedSnapshot.citations[sourceIndex]; | ||
| const host = new URL(citation.url).hostname.toLowerCase(); | ||
| return db.prepare(` | ||
| INSERT INTO web_search_candidate_sources ( | ||
| candidate_id, display_order, title, official_url, | ||
| official_host, url_sha256 | ||
| ) VALUES (?, ?, ?, ?, ?, ?) | ||
| `).bind( | ||
| record.candidateId, | ||
| nextDisplayOrder + offset, | ||
| citation.title, | ||
| citation.url, | ||
| host, | ||
| sourceHashes[sourceIndex], | ||
| ); | ||
| }), |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Apply the same host guard used by the new-record path.
The new-record insert at lines 792-796 rejects a non-official host with isOfficialGovernmentHost. The binding-upgrade insert omits that guard. Both paths write to web_search_candidate_sources.official_host. Keep the invariant identical in both places.
♻️ Proposed change
...missingSourceIndexes.map((sourceIndex, offset) => {
const citation = record.proposedSnapshot.citations[sourceIndex];
const host = new URL(citation.url).hostname.toLowerCase();
+ if (!isOfficialGovernmentHost(host)) {
+ throw new Error("official corpus source host is invalid");
+ }
return db.prepare(`📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ...missingSourceIndexes.map((sourceIndex, offset) => { | |
| const citation = record.proposedSnapshot.citations[sourceIndex]; | |
| const host = new URL(citation.url).hostname.toLowerCase(); | |
| return db.prepare(` | |
| INSERT INTO web_search_candidate_sources ( | |
| candidate_id, display_order, title, official_url, | |
| official_host, url_sha256 | |
| ) VALUES (?, ?, ?, ?, ?, ?) | |
| `).bind( | |
| record.candidateId, | |
| nextDisplayOrder + offset, | |
| citation.title, | |
| citation.url, | |
| host, | |
| sourceHashes[sourceIndex], | |
| ); | |
| }), | |
| ...missingSourceIndexes.map((sourceIndex, offset) => { | |
| const citation = record.proposedSnapshot.citations[sourceIndex]; | |
| const host = new URL(citation.url).hostname.toLowerCase(); | |
| if (!isOfficialGovernmentHost(host)) { | |
| throw new Error("official corpus source host is invalid"); | |
| } | |
| return db.prepare(` | |
| INSERT INTO web_search_candidate_sources ( | |
| candidate_id, display_order, title, official_url, | |
| official_host, url_sha256 | |
| ) VALUES (?, ?, ?, ?, ?, ?) | |
| `).bind( | |
| record.candidateId, | |
| nextDisplayOrder + offset, | |
| citation.title, | |
| citation.url, | |
| host, | |
| sourceHashes[sourceIndex], | |
| ); | |
| }), |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/official-corpus-drafts.ts` around lines 833 - 849, Update the
binding-upgrade insert within the missingSourceIndexes mapping to apply the same
isOfficialGovernmentHost validation used by the new-record path before writing
web_search_candidate_sources.official_host. Preserve the existing insert
behavior for official hosts and reject or handle non-official hosts consistently
with the new-record flow.
| export function reviewedCandidateMatchesChatTopic( | ||
| snapshot: ReviewedCandidateSnapshot, | ||
| topic: ChatTopic, | ||
| ) { | ||
| if (snapshot.topic !== chatTopicLabel(topic)) return false; | ||
| if (topic !== "copyright") return true; | ||
| return snapshot.tags | ||
| .map(normalizeVietnamese) | ||
| .some( | ||
| (tag) => | ||
| tag.includes("ban quyen") || | ||
| tag.includes("quyen tac gia") || | ||
| tag.includes("copyright"), | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
fd -a 'web-search-candidates\.ts$' . || true
echo "== outline =="
ast-grep outline lib/web-search-candidates.ts --view expanded || true
echo "== relevant sections =="
sed -n '1,130p' lib/web-search-candidates.ts
sed -n '720,790p' lib/web-search-candidates.ts
sed -n '800,830p' lib/web-search-candidates.ts
echo "== usages of subtypeSql/topicSql/reviewedCandidateMatchesChatTopic =="
rg -n "subtypeSql|topicSql|reviewedCandidateMatchesChatTopic|findReviewedWebCandidate|normalizeVietnamese|chatTopicLabel" lib/web-search-candidates.tsRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 9912
🌐 Web query:
SQLite SQL date time functions lower documentation does it change accents UTF8
💡 Result:
By default, the SQLite lower and upper functions only perform case conversion on 7-bit ASCII characters [1][2]. They do not change, recognize, or handle accents or any other non-ASCII Unicode characters [3][4]. If your data includes accented characters, the standard built-in lower and upper functions will leave them unchanged [2]. To enable full Unicode-aware case conversion, you must compile SQLite with the International Components for Unicode (ICU) extension enabled (-DSQLITE_ENABLE_ICU) [3][4]. Once this extension is loaded, the lower and upper functions gain the ability to perform locale-specific case mapping across the full Unicode range [3]. The design choice to limit these functions to ASCII by default exists to ensure stability and compatibility, as Unicode case-folding rules can change over time and the necessary tables for full support are significantly larger than the entire SQLite library itself [5][4].
Citations:
- 1: https://sqlite.org/lang_corefunc.html
- 2: http://www.iljitsch.com/2023/09-05-looking-at-sqlite-unicode-behavior.html
- 3: https://www.sqlite.org/src/dir?ci=trunk&name=ext%2Ficu
- 4: https://stackoverflow.com/questions/78172178/python-sqlite3-function-lower-upper-doesnt-work
- 5: https://sqlite.org/forum/forumpost/7d7305b181a6def2
🌐 Web query:
SQLite UTF8 lower function does not change letter case non lowercase letters
💡 Result:
The SQLite built-in lower() function is designed to convert characters to lowercase only for the 26 letters of the basic ASCII alphabet (A-Z) [1][2][3]. Consequently, it will not change the case of any non-ASCII characters, including accented letters, diacritics, or other Unicode characters that exist outside the ASCII range [4][5][6]. To perform case conversions or case-insensitive comparisons for the full range of Unicode characters, you must use an extension or custom implementation: 1. ICU Extension: The official SQLite ICU (International Components for Unicode) extension provides Unicode-aware versions of the lower() and upper() functions, as well as an enhanced LIKE operator that handles full Unicode case folding [2][7][8]. 2. Custom Collation or Functions: For applications requiring Unicode-aware case-insensitive behavior without the ICU extension, developers often register custom collation sequences or user-defined functions that handle Unicode case folding via host language libraries (e.g., Python's locale or casefold methods) [9][8][10]. 3. FTS5 Extension: The FTS5 full-text search extension includes a unicode61 tokenizer that performs Unicode folding, which can be leveraged for advanced text search requirements [11]. In summary, the default behavior you are observing is intentional; SQLite is optimized to be lightweight, and full Unicode case mapping is delegated to external extensions or application-level logic [5][8].
Citations:
- 1: https://www2.sqlite.org/lang_corefunc.html
- 2: https://www.sqlite.org/src/dir?ci=trunk&name=ext%2Ficu
- 3: https://database.guide/how-sqlite-lower-works/
- 4: https://sqlite.org/draft/lang_corefunc.html
- 5: https://sqlite.org/lang_expr.html
- 6: https://sqlite.org/lang_corefunc.html
- 7: https://github.com/sqlite/sqlite/blob/master/ext/icu/icu.c
- 8: https://unicodefyi.com/guide/unicode-in-sql/
- 9: https://shallowdepth.online/posts/2022/01/5-ways-to-implement-case-insensitive-search-in-sqlite-with-full-unicode-support/
- 10: https://www2.sqlite.org/draft/c3ref/create_collation.html
- 11: https://sqlite.org/forum/forumpost/524c146fbf
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== legal-content outline =="
ast-grep outline lib/legal-content.ts --view expanded || true
echo "== normalizeVietnamese implementation =="
sed -n '1,230p' lib/legal-content.ts
echo "== chat topic implementation =="
fd -a 'chat-topic-scope\.ts$' . | while read -r f; do
echo "--- $f"
ast-grep outline "$f" --view expanded || true
sed -n '1,220p' "$f"
doneRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 11407
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== behavioral SQL probe with default sqlite3 if available =="
if command -v sqlite3 >/dev/null 2>&1; then
for value in "Bản quyền học đường" "BẢN QUYỀN học đường" "bản quyền" "bÀN qUYềN"; do
printf '%s -> ' "$value"
sqlite3 :memory: "SELECT lower('$value');" | cat -A
done
else
echo "sqlite3 not available"
fi
echo "== JS normalization behavior =="
node - <<'JS'
const src = require('fs').readFileSync('lib/legal-content.ts','utf8');
console.log('contains unicodeNormalize/unaccent:', /unicodeNormalize|unaccent/i.test(src));
JS
echo "== import graph =="
printf '"lib/legal-content.ts"' > /tmp/roots.txt
python3 - <<'PY'
from pathlib import Path
roots = []
try:
with open('/tmp/roots.txt') as f:
roots = [p.strip() for p in f if p.strip()]
except FileNotFoundError:
pass
done = set()
for root in roots:
q = [root]
while q:
cur = q.pop(0)
if cur in done:
continue
done.add(cur)
text = Path(cur).read_text() if Path(cur).exists() else ''
for line in text.splitlines():
if 'from "' in line or "from '" in line:
_, _, rest = line.partition("from ")
mod = rest.strip().strip("'\"');
if '/' in mod:
path = str(Path(cur).parent / (mod + '.ts'))
else:
continue
if path not in done:
q.append(path)
print(f"{mod} -> {path}")
PYRepository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 502
Use the same Vietnamese normalization path for SQL copyright tag matching.
reviewedCandidateMatchesChatTopic strips Vietnamese diacritics and lowercases tags before matching ban quyen/quyen tac gia/copyright, but subtypeSql uses SQLite’s built-in lower() to match NFD-accented strings. SQLite default lower() is ASCII-only, so copyright tags such as BẢN QUYỀN or other case/diacritic variants can be filtered out before the JS-level check runs. Replace subtypeSql with a matching diacritic-stripped expression or remove it and rely on the JS check. Add a positive findReviewedWebCandidate test for a stored copyright-tagged candidate to catch SQL/JS matching regression.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/web-search-candidates.ts` around lines 78 - 93, Update subtypeSql used by
findReviewedWebCandidate to apply the same Vietnamese diacritic-stripping and
case-normalization as reviewedCandidateMatchesChatTopic, or remove the SQL tag
filter so the JS check handles matching consistently. Add a positive
findReviewedWebCandidate test with a stored candidate tagged using a copyright
variant such as BẢN QUYỀN, verifying it is returned.
| "smoke:ai": "node --env-file=.env.local --experimental-strip-types scripts/smoke-openai-evidence.mjs", | ||
| "smoke:web-search": "node --env-file=.env.local --experimental-strip-types scripts/smoke-openai-web-search.mjs", | ||
| "shadow:ai": "node --env-file=.env.local --experimental-strip-types scripts/shadow-openai-evidence.mjs", | ||
| "corpus:import:local": "node --experimental-strip-types scripts/import-official-corpus-drafts.mjs", |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -H -t f -d 2 '^(package.json|.nvmrc|.node-version|.tool-versions)$' -x cat -n {}
fd -H -t f -e yml -e yaml . .github -x rg -n 'node-version|setup-node' {} || true
rg -n 'experimental-strip-types|node:sqlite|registerHooks' --glob '!**/node_modules/**'Repository: tomnyson/tuyen_truyen_an_toan_giao_thong
Length of output: 3495
🌐 Web query:
Node.js module.registerHooks 22.15.0 release notes
💡 Result:
In Node.js 22.15.0, which was released on April 23, 2025, the module.registerHooks method was implemented [1][2]. This API allows users to register synchronous module customization hooks (specifically 'resolve' and 'load') that run directly on the same thread where modules are loaded [3][4]. This implementation was a significant addition designed to address several limitations of the existing module.register API, such as overhead from inter-thread communication and challenges with CommonJS module customization [5][3]. By running synchronously on the main thread, module.registerHooks is easier to debug and avoids many of the deadlock issues associated with the asynchronous, off-thread hooks used by module.register [3][4]. Since its introduction in v22.15.0, module.registerHooks has become the recommended approach for customizing module resolution and loading [5]. Consequently, the older module.register method has been marked as deprecated in favor of this synchronous alternative [5][6].
Citations:
- 1: nodejs/node@b009466
- 2: https://nodejs.org/en/blog/release/v22.15.0
- 3: implement module.registerHooks() to run synchronous module customization hooks in thread nodejs/node#55698
- 4: nodejs/node@e859646
- 5: https://nodejs.org/api/module.html
- 6: module: runtime deprecate
module.register()nodejs/node#62401
🌐 Web query:
Node.js node:sqlite available 22.5.0 release notes
💡 Result:
The node:sqlite module was introduced in Node.js version 22.5.0 [1][2]. Key details regarding its release include: - Purpose: The module provides built-in support for SQLite databases [3][4]. - Status at release: It was introduced as an experimental feature [4] with a stability rating of 1.1 (Active development) [3]. - Accessibility: The module is accessed via the node: scheme (e.g., import sqlite from 'node:sqlite' or require('node:sqlite')) [3]. It requires the --experimental-sqlite flag to be enabled to function [4]. - Functionality: The release included the DatabaseSync class for synchronous database operations, as well as the StatementSync class for managing SQL statements [3]. The addition of this module was documented in the Node.js 22.5.0 release notes [1][2], which cite the primary pull request (PR #53752) that implemented the feature [4].
Citations:
- 1: https://github.com/nodejs/node/releases/tag/v22.5.0
- 2: https://nodejs.org/en/blog/release/v22.5.0
- 3: https://r2.nodejs.org/dist/v22.5.0/docs/api/sqlite.html
- 4: lib,src,test,doc: add node:sqlite module nodejs/node#53752
🌐 Web query:
Node.js experimental-strip-types flag experimental 22
💡 Result:
The --experimental-strip-types flag was a feature introduced in Node.js v22 to allow the runtime to execute TypeScript files natively by stripping out type annotations [1][2][3]. Key details regarding its evolution and current status in Node.js: Functionality: The feature performs "type stripping," where it removes TypeScript type annotations and replaces them with whitespace to produce valid JavaScript [4][2]. It does not perform type checking [4][3]. Because it simply removes types, it does not support TypeScript features that require code transformation (such as enums, namespaces, or parameter properties) unless the additional --experimental-transform-types flag is used [2][3]. Additionally, Node.js does not read tsconfig.json files, meaning features dependent on that configuration (like path mapping or down-level compilation) are unsupported [4][5]. Evolution in Node.js 22: - Initial Introduction: The feature was introduced as experimental [6][2]. - Status Change: Starting in Node.js v22.18.0, the type stripping feature no longer emits an experimental warning and is enabled by default [4][2]. - Current Usage: Because it is enabled by default, you do not need to use the --experimental-strip-types flag in current versions of Node.js 22 (v22.18.0 and later) [4][2]. If you wish to disable the feature, you can use the --no-experimental-strip-types flag [1][7]. Later Versions: In later versions of Node.js (such as v24+ and v25+), type stripping is considered stable, and the functionality is now standard [4][5].
Citations:
- 1: https://nodejs.org/learn/typescript/run-natively
- 2: https://nodejs.org/docs/latest-v22.x/api/typescript.html
- 3: https://nodejs.org/docs/v22.12.0/api/typescript.html
- 4: https://nodejs.org/docs/latest/api/typescript.html
- 5: https://nodejs.org/api/typescript.html
- 6: module: add --experimental-strip-types nodejs/node#53725
- 7: module: unflag --experimental-strip-types nodejs/node#56350
Update the Node.js floor for corpus:import:local.
package.json currently declares node >=22.13.0, but scripts/import-official-corpus-drafts.mjs uses module.registerHooks(), which is only available from Node.js 22.15.0. This will fail on the declared floor with an unclear error; raise the engines.node minimum to at least 22.15.0.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@package.json` at line 28, Raise the package.json engines.node minimum from
22.13.0 to at least 22.15.0 so the corpus:import:local script using
module.registerHooks() runs on every supported Node.js version.
| class PreparedStatement { | ||
| constructor(database, sql, values = []) { | ||
| this.database = database; | ||
| this.sql = sql; | ||
| this.values = values; | ||
| } | ||
|
|
||
| bind(...values) { | ||
| return new PreparedStatement(this.database, this.sql, values); | ||
| } | ||
|
|
||
| execute() { | ||
| const results = this.database.prepare(this.sql).all(...this.values); | ||
| return { success: true, results }; | ||
| } | ||
| } | ||
|
|
||
| class LocalD1Adapter { | ||
| constructor(database) { | ||
| this.database = database; | ||
| } | ||
|
|
||
| prepare(sql) { | ||
| return new PreparedStatement(this.database, sql); | ||
| } | ||
|
|
||
| async batch(statements) { | ||
| this.database.exec("BEGIN IMMEDIATE"); | ||
| try { | ||
| const results = statements.map((statement) => statement.execute()); | ||
| this.database.exec("COMMIT"); | ||
| return results; | ||
| } catch (error) { | ||
| this.database.exec("ROLLBACK"); | ||
| throw error; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Extract the local D1 adapter into one shared helper. Both files implement the same prepare/bind/execute wrapper and the same BEGIN IMMEDIATE batch adapter with different class names. The shared root cause is a missing shared module for local D1 emulation, so both copies must track the D1Like contract and the success flag independently.
scripts/import-official-corpus-drafts.mjs#L24-L61: movePreparedStatementandLocalD1Adapterinto a shared module, for examplelib/local-d1-adapter.mjs, and import them here. Move the duplicatedregisterHooksresolve hook at lines 7-18 into the same module.tests/official-corpus-drafts.test.mjs#L55-L94: deletePreparedandD1Adapterand import the shared adapter instead, so the test exercises the same emulation as the script.
📍 Affects 2 files
scripts/import-official-corpus-drafts.mjs#L24-L61(this comment)tests/official-corpus-drafts.test.mjs#L55-L94
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/import-official-corpus-drafts.mjs` around lines 24 - 61, Extract
PreparedStatement, LocalD1Adapter, and the duplicated registerHooks resolve hook
into a shared module such as lib/local-d1-adapter.mjs; update
scripts/import-official-corpus-drafts.mjs lines 24-61 to import and use it. In
tests/official-corpus-drafts.test.mjs lines 55-94, delete the local Prepared and
D1Adapter implementations and import the shared adapter so both consumers
independently preserve the D1Like contract and success flag.
| test("official-looking output with the wrong source kind is never persisted", async () => { | ||
| let persisted = 0; | ||
| Object.assign(globalThis.__topicScopeWorkerEnv, { | ||
| AI_WEB_SEARCH_ENABLED: "true", | ||
| OPENAI_API_KEY: "test-key", | ||
| OPENAI_MODEL: "gpt-5.4-mini", | ||
| }); | ||
| const chat = createChatHandler({ | ||
| limiter: () => ({ consumeChat: async () => allowed }), | ||
| telemetry: { emit() {} }, | ||
| managedAnswer: async () => null, | ||
| curatedAnswer: () => null, | ||
| reviewedWebAnswer: async () => null, | ||
| reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }), | ||
| settleWebBudget: async () => true, | ||
| persistWebCandidate: async () => { | ||
| persisted += 1; | ||
| return "33333333-3333-4333-8333-333333333333"; | ||
| }, | ||
| webSearch: async () => ({ | ||
| ok: true, | ||
| sourceKind: "reference", | ||
| answer: "Kết luận: Nội dung sai trust tier.", | ||
| sections: [], | ||
| warning: "Cảnh báo", | ||
| sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }], | ||
| model: "gpt-5.4-mini", | ||
| usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, | ||
| }), | ||
| }); | ||
|
|
||
| const body = await (await chat(request("Quy định giao thông mới?"))).json(); | ||
| assert.equal(body.mode, "unavailable"); | ||
| assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER); | ||
| assert.equal(persisted, 0); | ||
| for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) { | ||
| delete globalThis.__topicScopeWorkerEnv[key]; | ||
| } | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Delete the injected worker env in a failure-safe hook.
Each provider test assigns keys to globalThis.__topicScopeWorkerEnv and deletes them after the assertions. If an assertion throws, the delete loop never runs. AI_WEB_SEARCH_ENABLED=true then leaks into the following tests, which run in the same process, and one real failure cascades into unrelated failures. The same pattern repeats at Lines 285-325, 327-371, 373-418, 420-487, 489-535 and 537-621.
Use the test context cleanup hook so the reset always runs.
♻️ Proposed refactor for one site; apply the same change to every provider test
-test("official-looking output with the wrong source kind is never persisted", async () => {
+test("official-looking output with the wrong source kind is never persisted", async (t) => {
let persisted = 0;
+ t.after(() => {
+ for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
+ delete globalThis.__topicScopeWorkerEnv[key];
+ }
+ });
Object.assign(globalThis.__topicScopeWorkerEnv, {
AI_WEB_SEARCH_ENABLED: "true",
OPENAI_API_KEY: "test-key",
OPENAI_MODEL: "gpt-5.4-mini",
});Then remove the trailing delete loop from each test body.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| test("official-looking output with the wrong source kind is never persisted", async () => { | |
| let persisted = 0; | |
| Object.assign(globalThis.__topicScopeWorkerEnv, { | |
| AI_WEB_SEARCH_ENABLED: "true", | |
| OPENAI_API_KEY: "test-key", | |
| OPENAI_MODEL: "gpt-5.4-mini", | |
| }); | |
| const chat = createChatHandler({ | |
| limiter: () => ({ consumeChat: async () => allowed }), | |
| telemetry: { emit() {} }, | |
| managedAnswer: async () => null, | |
| curatedAnswer: () => null, | |
| reviewedWebAnswer: async () => null, | |
| reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }), | |
| settleWebBudget: async () => true, | |
| persistWebCandidate: async () => { | |
| persisted += 1; | |
| return "33333333-3333-4333-8333-333333333333"; | |
| }, | |
| webSearch: async () => ({ | |
| ok: true, | |
| sourceKind: "reference", | |
| answer: "Kết luận: Nội dung sai trust tier.", | |
| sections: [], | |
| warning: "Cảnh báo", | |
| sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }], | |
| model: "gpt-5.4-mini", | |
| usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, | |
| }), | |
| }); | |
| const body = await (await chat(request("Quy định giao thông mới?"))).json(); | |
| assert.equal(body.mode, "unavailable"); | |
| assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER); | |
| assert.equal(persisted, 0); | |
| for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) { | |
| delete globalThis.__topicScopeWorkerEnv[key]; | |
| } | |
| }); | |
| test("official-looking output with the wrong source kind is never persisted", async (t) => { | |
| let persisted = 0; | |
| t.after(() => { | |
| for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) { | |
| delete globalThis.__topicScopeWorkerEnv[key]; | |
| } | |
| }); | |
| Object.assign(globalThis.__topicScopeWorkerEnv, { | |
| AI_WEB_SEARCH_ENABLED: "true", | |
| OPENAI_API_KEY: "test-key", | |
| OPENAI_MODEL: "gpt-5.4-mini", | |
| }); | |
| const chat = createChatHandler({ | |
| limiter: () => ({ consumeChat: async () => allowed }), | |
| telemetry: { emit() {} }, | |
| managedAnswer: async () => null, | |
| curatedAnswer: () => null, | |
| reviewedWebAnswer: async () => null, | |
| reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }), | |
| settleWebBudget: async () => true, | |
| persistWebCandidate: async () => { | |
| persisted += 1; | |
| return "33333333-3333-4333-8333-333333333333"; | |
| }, | |
| webSearch: async () => ({ | |
| ok: true, | |
| sourceKind: "reference", | |
| answer: "Kết luận: Nội dung sai trust tier.", | |
| sections: [], | |
| warning: "Cảnh báo", | |
| sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }], | |
| model: "gpt-5.4-mini", | |
| usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, | |
| }), | |
| }); | |
| const body = await (await chat(request("Quy định giao thông mới?"))).json(); | |
| assert.equal(body.mode, "unavailable"); | |
| assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER); | |
| assert.equal(persisted, 0); | |
| for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) { | |
| delete globalThis.__topicScopeWorkerEnv[key]; | |
| } | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/chat-topic-scope.test.mjs` around lines 245 - 283, Update every
provider test identified in this file, including the test containing
`official-looking output with the wrong source kind is never persisted`, to
register worker-environment cleanup through the test context’s failure-safe
cleanup hook. Move each `globalThis.__topicScopeWorkerEnv` key deletion into
that hook and remove the corresponding trailing delete loop from the test body,
preserving the existing test setup and assertions.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
app/api/chat/route.ts (1)
487-523: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winEnforce the unverified label for every reference result.
The route forwards
referenceResult.warningwithout enforcingchưa kiểm chứng/chỉ tham khảo. An unreviewed fine can therefore reach the client with arbitrary provider text.
app/api/chat/route.ts#L487-L523: return a server-defined warning containingchưa kiểm chứng/chỉ tham khảo, or reject warnings that do not contain that label.tests/chat-topic-scope.test.mjs#L453-L481: assert that the returned warning contains the required label.Proposed fix
+const referenceWarning = + "Chưa kiểm chứng/chỉ tham khảo: Nội dung từ nguồn tham khảo ngoài và cần được đối chiếu với văn bản chính thức."; + ... - warning: referenceResult.warning, + warning: referenceWarning,As per coding guidelines, unreviewed web-search output must be prominently labelled
chưa kiểm chứng/chỉ tham khảo.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/chat/route.ts` around lines 487 - 523, Ensure the reference-result response in app/api/chat/route.ts lines 487-523 always exposes a server-controlled warning containing “chưa kiểm chứng/chỉ tham khảo”, rather than forwarding arbitrary referenceResult.warning; reject or replace warnings that lack the required label. Update the corresponding assertion in tests/chat-topic-scope.test.mjs lines 453-481 to verify the returned warning contains that label.Source: Coding guidelines
app/page.tsx (2)
560-560: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUse the exact supported copyright scope.
The placeholder ends with
bản quyền…, but the documented supported topic isbản quyền học đường. The current wording can invite questions outside the supported school-copyright scope.- placeholder="Hỏi về giao thông, an toàn/ứng xử trên mạng hoặc bản quyền…" + placeholder="Hỏi về giao thông, an toàn/ứng xử trên mạng hoặc bản quyền học đường…"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/page.tsx` at line 560, Update the placeholder in the page component to use the exact supported topic “bản quyền học đường” instead of the broader “bản quyền”, while preserving the existing wording and punctuation for the other topics.
127-155: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftPreserve every published citation.
PublishedContent.laws[].citationsis an array, but this mapping selects onlyitem.citations?.[0]and stores oneLawItem.citation. The modal then exposes only one official link. Records with multiple approved citations lose source traceability, contrary todocs/PRODUCT_REQUIREMENTS.mdLines 213–224.Keep the citation array through
LawItemand render one source link per citation. Add a regression with two citations.Suggested projection change
- const firstCitation = item.citations?.[0]; + const citations = item.citations ?? []; ... - citation: firstCitation ? { - ... - } : undefined, + citations: citations.map((citation) => ({ + ... + })),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/page.tsx` around lines 127 - 155, Update the law projection in the managed-laws mapping to preserve the full item.citations array instead of selecting firstCitation, extending the LawItem shape and related consumers as needed. Update the modal rendering to expose one official source link for every citation while retaining the existing citation field mappings and empty-array behavior. Add a regression test covering a law with two citations and verifying both source links are rendered.docs/PROGRESS.md (1)
24-29: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRecalculate the overview totals.
The story table at Lines 35–64 contains 30 rows: 10
Doneand 20Partial. The overview reports 10Doneand 18Partial. Unless two stories are intentionally excluded and documented, the progress summary is inaccurate.As per coding guidelines, status completion requires verification evidence and progress records must reflect the implementation state.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/PROGRESS.md` around lines 24 - 29, Update the overview total row in PROGRESS.md to reflect all 30 story rows: 10 Done and 20 Partial, unless the two excluded stories are explicitly documented. Keep the category totals consistent with the corrected summary and the implementation evidence requirements.Source: Coding guidelines
lib/openai-web-search.ts (1)
222-226: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winFix blank
OPENAI_WEB_SEARCH_MODELfallback handling.An empty override is selected instead of falling back to
OPENAI_MODEL. The example configuration therefore causes direct web search to fail withINVALID_CONFIG.
lib/openai-web-search.ts#L222-L226: selectOPENAI_WEB_SEARCH_MODELonly when it is non-empty, then fall back toOPENAI_MODEL..env.example#L32-L35: retain the blank override example only after the parser implements that fallback.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/openai-web-search.ts` around lines 222 - 226, Update the model selection logic near the OpenAI web-search configuration in lib/openai-web-search.ts lines 222-226 to use OPENAI_WEB_SEARCH_MODEL only when it is non-empty, otherwise fall back to OPENAI_MODEL. Retain the blank override example in .env.example lines 32-35 after this fallback behavior is implemented.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/PRODUCT_REQUIREMENTS.md`:
- Around line 609-615: Add the missing DEC-013 entry to the decision log in
PRODUCT_REQUIREMENTS.md, using the canonical topic-gate decision recorded in
TECHNICAL_SPEC.md or PROGRESS.md, or link to that canonical entry. Preserve the
existing chronological decision-log structure and ensure the entry remains
traceable to the relevant user story and technical specification.
In `@docs/TECHNICAL_SPEC.md`:
- Around line 1172-1177: Update the canonical /api/chat response contract near
the earlier response-contract section to include the web_search mode,
sourceKind, warnings, and structured sections consumed by app/page.tsx, or
explicitly mark that older knowledge/unavailable contract as legacy. Ensure the
documentation no longer presents contradictory response modes.
- Around line 2379-2386: Update the earlier reference-output rules around the
policy section identified by the quantitative restrictions so they no longer
require general guidance, reject provider-supplied amounts/document
details/provisions/dates, or replace that prose with a safe fallback; align them
with DEC-017, or explicitly mark them as superseded by DEC-017, leaving one
normative policy.
---
Outside diff comments:
In `@app/api/chat/route.ts`:
- Around line 487-523: Ensure the reference-result response in
app/api/chat/route.ts lines 487-523 always exposes a server-controlled warning
containing “chưa kiểm chứng/chỉ tham khảo”, rather than forwarding arbitrary
referenceResult.warning; reject or replace warnings that lack the required
label. Update the corresponding assertion in tests/chat-topic-scope.test.mjs
lines 453-481 to verify the returned warning contains that label.
In `@app/page.tsx`:
- Line 560: Update the placeholder in the page component to use the exact
supported topic “bản quyền học đường” instead of the broader “bản quyền”, while
preserving the existing wording and punctuation for the other topics.
- Around line 127-155: Update the law projection in the managed-laws mapping to
preserve the full item.citations array instead of selecting firstCitation,
extending the LawItem shape and related consumers as needed. Update the modal
rendering to expose one official source link for every citation while retaining
the existing citation field mappings and empty-array behavior. Add a regression
test covering a law with two citations and verifying both source links are
rendered.
In `@docs/PROGRESS.md`:
- Around line 24-29: Update the overview total row in PROGRESS.md to reflect all
30 story rows: 10 Done and 20 Partial, unless the two excluded stories are
explicitly documented. Keep the category totals consistent with the corrected
summary and the implementation evidence requirements.
In `@lib/openai-web-search.ts`:
- Around line 222-226: Update the model selection logic near the OpenAI
web-search configuration in lib/openai-web-search.ts lines 222-226 to use
OPENAI_WEB_SEARCH_MODEL only when it is non-empty, otherwise fall back to
OPENAI_MODEL. Retain the blank override example in .env.example lines 32-35
after this fallback behavior is implemented.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 43214507-0336-4ecd-aa34-40f8e92b79fc
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (17)
.env.exampleAGENTS.mdapp/api/chat/route.tsapp/page.tsxdocs/PRODUCT_REQUIREMENTS.mddocs/PROGRESS.mddocs/TECHNICAL_SPEC.mddocs/USER_STORIES.mdlib/chat-answer-presentation.tslib/chat-topic-scope.tslib/legal-chat.tslib/openai-web-search.tslib/web-search-candidates.tspackage.jsonscripts/smoke-openai-web-search.mjstests/chat-topic-scope.test.mjstests/openai-web-search.test.mjs
| | 2026-07-31 | DEC-012 | Khi official web search không có câu trả lời đủ điều kiện, cho phép thêm một lượt reference search có kiểm soát. | Reference allowlist ban đầu chỉ có `thuvienphapluat.vn`; output phải ghi rõ không chính thống/cần xác minh và không persist/candidate/RAG. Hạn chế định lượng ban đầu được DEC-017 thay đổi. | | ||
| | 2026-07-31 | DEC-011 | Kết quả web qua official guard được lưu thành immutable draft không chứa raw question. | D1 persistence là điều kiện trước khi trả web result; chỉ stable principal + independent reviewer mới publish candidate vào reviewed retrieval. | | ||
| | 2026-07-31 | DEC-014 | Official corpus seed là review packet versioned, import idempotent vào draft-only và bind full-record hash vào audit metadata. | Không auto-publish; legal record đi tiếp qua workflow hiện có, còn canonical question không được lưu vào D1. | | ||
| | 2026-07-31 | DEC-015 | MVP được trả ngay official safety guidance không đưa ra căn cứ, tội danh hoặc mức phạt; không nới gate cho legal candidate. | Hướng dẫn tài khoản bị chiếm có source Chính phủ và form ngắn; API/CMS chặn record này đi vào legal RAG. Quy trình bốn mắt vẫn giữ cho mọi legal citation/sanction. | | ||
| | 2026-07-31 | DEC-016 | Direct web-search chấp nhận mọi `OPENAI_MODEL` có model ID định dạng an toàn thay vì exact allowlist. | Model là cấu hình server-only, không nhận từ request người dùng. Model không có Responses API/web-search capability trả lỗi an toàn; offline evidence/shadow vẫn giữ exact model policy để tái lập evaluation. | | ||
| | 2026-07-31 | DEC-017 | Chủ dự án chấp nhận hiển thị mức phạt/chi tiết pháp lý do web-search trả về dù chưa được kiểm chứng. | Bắt buộc cảnh báo nổi bật “chỉ tham khảo/chưa kiểm chứng”, source link qua exact authority guard và không gọi là reviewed evidence. Official result vẫn chỉ lưu draft; reference không persist/RAG. | | ||
| | 2026-07-31 | DEC-018 | Hosted web-search được chờ tối đa 30 giây và lỗi provider phải được phân biệt với no-match. | Cấu hình mẫu/default dùng 30 giây. Khi provider timeout/error/refusal, end user nhận thông báo dịch vụ tra cứu chưa hoàn tất và có thể thử lại; không nhận thông báo sai rằng nguồn không có dữ liệu. | |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Add DEC-013 to this decision log.
The topic-gate decision is a material product decision. docs/TECHNICAL_SPEC.md and docs/PROGRESS.md record DEC-013, but this log jumps from DEC-012 and DEC-011 to DEC-014. Add the same decision or link the canonical entry.
As per coding guidelines, documentation-driven delivery requires every implementation change to stay traceable to a user story and a technical specification.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/PRODUCT_REQUIREMENTS.md` around lines 609 - 615, Add the missing DEC-013
entry to the decision log in PRODUCT_REQUIREMENTS.md, using the canonical
topic-gate decision recorded in TECHNICAL_SPEC.md or PROGRESS.md, or link to
that canonical entry. Preserve the existing chronological decision-log structure
and ensure the entry remains traceable to the relevant user story and technical
specification.
Source: Coding guidelines
| 3. Reference live search giữ section | ||
| `summary|details|sanctions|next_steps|limitations`, loại | ||
| `legal_basis|legal_remedies|examples`, luôn cảnh báo không chính thống/chưa | ||
| kiểm chứng và không gọi persistence. Provider answer phải tự match topic đã | ||
| phân loại trước khi hiển thị. Section `sanctions` chỉ có khi kết quả search | ||
| chứa mức phạt; không có mức phạt thì section bị bỏ hoàn toàn. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Update the canonical /api/chat mode contract.
This section documents web_search, sourceKind, warnings, and structured sections. The earlier response contract at Lines 114–124 still lists only knowledge and unavailable. app/page.tsx consumes web_search and sections.
Update the earlier contract or mark it as legacy. Otherwise API clients receive contradictory documentation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/TECHNICAL_SPEC.md` around lines 1172 - 1177, Update the canonical
/api/chat response contract near the earlier response-contract section to
include the web_search mode, sourceKind, warnings, and structured sections
consumed by app/page.tsx, or explicitly mark that older knowledge/unavailable
contract as legacy. Ensure the documentation no longer presents contradictory
response modes.
| - **DEC-017:** Product owner chấp nhận rủi ro MVP và cho phép direct | ||
| official/reference web-search giữ số tiền, số hiệu văn bản, điều-khoản-điểm, | ||
| ngày hiệu lực và biện pháp pháp lý do provider trả về. Public response phải | ||
| có warning trước nội dung, dùng nhãn “tham khảo — chưa kiểm chứng”, chỉ render | ||
| source URL qua exact authority guard và không mô tả output là reviewed | ||
| application evidence. Official result chỉ persist immutable draft; | ||
| reference result live/no-store, không candidate/RAG. Thiếu source hợp lệ, | ||
| malformed/active content hoặc sai topic vẫn fail closed. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Remove or mark the superseded quantitative restriction.
DEC-017 now permits unverified amounts, document numbers, provisions, and dates under a warning. The earlier rules at Lines 1572–1580 still require reference output to be general guidance, reject these fields, and replace the provider prose with a safe fallback.
Keep one normative policy. Update the earlier rules or mark them as superseded by DEC-017.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/TECHNICAL_SPEC.md` around lines 2379 - 2386, Update the earlier
reference-output rules around the policy section identified by the quantitative
restrictions so they no longer require general guidance, reject
provider-supplied amounts/document details/provisions/dates, or replace that
prose with a safe fallback; align them with DEC-017, or explicitly mark them as
superseded by DEC-017, leaving one normative policy.
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Documentation