Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ DATABASE_URL=
# under-18, budget/rate-limit and rollout review pass.
# Store OPENAI_API_KEY only in a server-side secret manager.
OPENAI_API_KEY=
# Direct web-search accepts any syntactically valid OpenAI model ID.
OPENAI_MODEL=gpt-5.4-mini
# Optional override for web search only; falls back to OPENAI_MODEL. The model
# must support the web_search tool with domain filters (the Government-source
Expand All @@ -42,7 +43,7 @@ AI_WEB_SEARCH_ENABLED=false
# the provider and reconciles actual usage afterwards.
AI_WEB_SEARCH_DAILY_TOKEN_BUDGET=500000
AI_WEB_SEARCH_RESERVATION_TOKENS=12000
AI_PROVIDER_TIMEOUT_MS=10000
AI_PROVIDER_TIMEOUT_MS=30000
AI_PROVIDER_MAX_REQUESTS_PER_MINUTE=30
AI_SHADOW_MAX_CASES=5

Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ must stay traceable to a user story and a technical specification.
consistent.
- Code review prioritizes correctness, legal-source integrity, security,
regressions, and missing tests.
- Legal citations shown to users must come from reviewed application data. AI
output must not invent or silently alter a citation.
- Legal citations presented as verified application evidence must come from
reviewed application data. For MVP, unreviewed web-search output may show a
reference fine, document number, provision or effective date only when the
response is prominently labelled "chưa kiểm chứng/chỉ tham khảo", keeps its
guarded consulted-source link, and is not promoted to reviewed RAG evidence.
AI output must not invent or silently alter a citation.

## Status vocabulary

Expand All @@ -39,4 +43,3 @@ must stay traceable to a user story and a technical specification.
- `Partial`: some acceptance criteria have evidence, but the story is incomplete.
- `Done`: every acceptance criterion is checked and has verification evidence.
- `Blocked`: progress requires an explicit external decision or dependency.

23 changes: 15 additions & 8 deletions app/admin/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type CandidateCitation = {
issuedAt?: string;
effectiveFrom: string;
effectiveTo?: string;
effectivityNote?: string;
lastVerifiedAt: string;
};
type CandidateSnapshot = {
Expand All @@ -59,6 +60,10 @@ type CandidateRow = {
createdAt: string;
updatedAt: string;
snapshot: CandidateSnapshot | null;
draftSnapshot: CandidateSnapshot | null;
intakeTitle: string;
intakeAnswer: string;
publicationEligible: boolean;
sources: Array<{ title: string; url: string }>;
history: Array<{
action: string;
Expand Down Expand Up @@ -301,7 +306,7 @@ function CandidatePanel() {
const today = new Date().toISOString().slice(0, 10);
setEditing(candidate);
setDraft(
candidate.snapshot ?? {
candidate.snapshot ?? candidate.draftSnapshot ?? {
topic: "Giao thông",
title: "",
answer: candidate.initialAnswer,
Expand Down Expand Up @@ -407,6 +412,7 @@ function CandidatePanel() {
<label>Ngày ban hành<input type="date" required value={citation.issuedAt ?? ""} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, issuedAt: event.target.value } : item) })} /></label>
<label>Ngày hiệu lực<input type="date" required value={citation.effectiveFrom} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, effectiveFrom: event.target.value } : item) })} /></label>
<label>Ngày hết hiệu lực<input type="date" value={citation.effectiveTo ?? ""} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, effectiveTo: event.target.value || undefined } : item) })} /></label>
<label>Ghi chú hiệu lực<input value={citation.effectivityNote ?? ""} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, effectivityNote: event.target.value || undefined } : item) })} /></label>
<label>Ngày kiểm chứng<input type="date" required value={citation.lastVerifiedAt} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, lastVerifiedAt: event.target.value } : item) })} /></label>
<label>Điều<input value={citation.article ?? ""} onChange={(event) => setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, article: event.target.value } : item) })} /></label>
<label>Khoản / điểm<input value={[citation.clause, citation.point].filter(Boolean).join(" / ")} onChange={(event) => { const [clause, point] = event.target.value.split("/").map((value) => value.trim()); setDraft({ ...draft, citations: draft.citations.map((item, itemIndex) => itemIndex === index ? { ...item, clause, point } : item) }); }} /></label>
Expand All @@ -422,15 +428,16 @@ function CandidatePanel() {
<span className={`admin-status ${candidate.status}`}>{candidate.status}</span>
<small>{candidate.providerModel} · {candidate.totalTokens ?? "?"} tokens</small>
</div>
<h3>{candidate.snapshot?.title || "Bản nháp chưa được biên tập"}</h3>
<p>{candidate.snapshot?.answer || candidate.initialAnswer}</p>
<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>}
Comment on lines +431 to +440

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

</div>
<details>
<summary>Lịch sử ({candidate.history.length})</summary>
Expand Down
61 changes: 61 additions & 0 deletions app/admin/api/web-search-candidates/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,36 @@ export async function GET(request: Request) {
snapshot = null;
}
}
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.
}
}
Comment on lines +87 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.tsx

Repository: 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.

return {
id,
initialAnswer: candidate.initial_answer_text,
Expand All @@ -99,6 +129,10 @@ export async function GET(request: Request) {
createdAt: candidate.created_at,
updatedAt: candidate.updated_at,
snapshot,
draftSnapshot: publicationEligible ? intakeDraft : null,
intakeTitle,
intakeAnswer,
publicationEligible,
sources: (sourcesByCandidate.get(id) ?? []).map((source) => ({
title: source.title,
url: source.official_url,
Expand Down Expand Up @@ -151,6 +185,33 @@ export async function PATCH(request: Request) {
return noStoreJson({ error: "Candidate hoặc version không hợp lệ." }, 400);
}
try {
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,
);
}
Comment on lines +188 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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 -S

Repository: 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.ts

Repository: 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.ts

Repository: 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.

const result =
action === "save_revision"
? await saveWebSearchCandidateRevision(
Expand Down
Loading