Monorepo Public/Internal Split — Plan C: Polish + Full Backlog
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Clear the entire post-cutover backlog from Plans A+B — get the app from "infrastructure ships, RAG empty" to "user-visible legal answers with polished admin, resilient streaming, secure SQL guard, and clean dev ergonomics."
Architecture: Eight independent-ish phases (A-I skipping D-as-standalone). Phase A (RAG load) is the only phase whose result end users notice; run it first so the app becomes useful before the polish work lands. Phases B-H iterate on infra polish + admin UX + defense-in-depth. Phase I is the safe-window physical rename.
Tech Stack: Next.js 15 + React 19 + Tailwind 4 (admin UI), TypeScript (orchestrator), Python (dash agents + RAG), BullMQ + ioredis (workers), Milvus + Qwen3-Embedding-4B (RAG), node-sql-parser (SQL AST).
Prior plans:
- 2026-06-30-monorepo-split-plan-a-skeleton-and-refactor.md (Phase 0+1 — code split + TS orchestrator)
- 2026-07-01-monorepo-split-plan-b-deploy-and-cutover.md (Phase 2+3 — deploy + cutover)
Spec: docs/superpowers/specs/2026-06-30-monorepo-public-internal-split-design.md
Where this plan runs
- Phase A (RAG load) runs on Server 2 (192.168.83.20) where Milvus + embedding model + vLLM live. Ingestion job runs inside
ai-ahu-ragcontainer. - Phases B, C, D, F, G, H run on Server 1 =
103.30.246.154(the edge host you're on). Source-of-truth for the monorepo. - Phase E (BullMQ workers) runs on Server 2 alongside the app containers.
- Phase I (rename) is a filesystem operation on Server 1 with a coordination window.
Phase A — RAG corpus load (unblocks real user-visible answers)
Task A1: Inventory available AHU legal docs
Files: none (audit task)
- [x] Step 1: Enumerate candidate source directories
Check the three most likely sources on Server 1:
find /home/efran -maxdepth 3 -type d -iname "*legal*" -o -iname "*hukum*" -o -iname "*akta*" 2>/dev/null | head -20
find /home/efran -maxdepth 3 -name "*.pdf" -o -name "*.txt" 2>/dev/null | wc -l
ls /home/efran/remote-development/poc-ahu-ai/ 2>/dev/null | grep -i "doc\|legal\|hukum\|ocr"
Expected: identifies at least one directory with AHU legal PDFs or text.
- [x] Step 2: Record inventory in docs
Create docs/superpowers/rag-corpus-inventory.md listing:
- Path
- Approximate document count
- Format (PDF / text / DOCX)
- Coverage areas (PT, perseroan, fidusia, notaris, etc.)
- Any known preprocessing already done (OCR? plain text extract?)
- [x] Step 3: Commit
git add docs/superpowers/rag-corpus-inventory.md
git commit -m "docs(rag): inventory candidate AHU legal doc sources for corpus load"
Task A2: Verify Milvus + embedding + RAG service state
Files: none (health-check task)
- [x] Step 1: Check Milvus reachability from within ahu-net
ssh obert@192.168.83.20 "docker exec ai-ahu-rag curl -s http://milvus:19530/v1/vector/collections 2>&1 | head -5"
Expected: JSON list of collections (may be empty), no connection error.
- [x] Step 2: Check embedding model status
ssh obert@192.168.83.20 "curl -sf http://192.168.83.20:8110/health 2>&1 | head -3"
Expected: some form of ok response from ai-ahu-rag.
- [x] Step 3: Test embedding endpoint with a single sentence
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.embed import embed_texts; v = embed_texts([\"apa itu perseroan\"]); print(len(v[0]))'"
Expected: prints an embedding dimension (typically 3584 for Qwen3-Embedding-4B).
- [x] Step 4: If any of Step 1-3 fail, document + escalate
Create docs/superpowers/rag-infra-blockers.md describing what's broken and skip to Task A3 only after resolution.
Task A3: Ingest inventoried docs into Milvus
Files:
- Reference: ai-ahu-rag's existing ingestion script (per its own repo)
- [x] Step 1: SSH into ai-ahu-rag container + list existing ingest tooling
ssh obert@192.168.83.20 "docker exec ai-ahu-rag ls /app/rag/scripts/ 2>&1 | head"
Expected: shows one or more .py files (likely ingest.py, load_docs.py, or similar).
- [x] Step 2: Copy source PDFs into ai-ahu-rag's mount
Identify where ai-ahu-rag reads docs from (check its compose volumes). Copy the inventoried source dir into that mount from Server 1 via SSH.
# Example — adjust paths based on Task A1 inventory + ai-ahu-rag's actual mount:
rsync -avz /home/efran/remote-development/poc-ahu-ai/<INVENTORY_DIR>/ obert@192.168.83.20:/home/obert/ahu-rag-corpus/source-2026-07/
- [x] Step 3: Run the ingestion job
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -m rag.scripts.ingest --source /data/source-2026-07 --collection ahu_legal_v1 2>&1 | tail -30"
Expected: log lines showing docs chunked + embedded + inserted. Final line reports counts.
- [x] Step 4: Verify collection is populated
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.milvus import stats; print(stats(\"ahu_legal_v1\"))'"
Expected: shows row count > 0.
- [x] Step 5: Commit ingest run notes
mkdir -p docs/superpowers/parity-snapshots/2026-07-01-rag-load
echo "ran at $(date -Iseconds)" > docs/superpowers/parity-snapshots/2026-07-01-rag-load/ingest-run.txt
ssh obert@192.168.83.20 "docker exec ai-ahu-rag python -c 'from rag.milvus import stats; print(stats(\"ahu_legal_v1\"))'" >> docs/superpowers/parity-snapshots/2026-07-01-rag-load/ingest-run.txt
git add docs/superpowers/parity-snapshots/2026-07-01-rag-load/
git commit -m "docs(rag): ingest run 2026-07-01 — <N> docs loaded into ahu_legal_v1"
Task A4: Re-run prod parity smoke — verify RAG returns non-empty
Files:
- Update: docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt
- [x] Step 1: Re-run the 5-question smoke against live prod
for i in 1 2 3 4 5; do
case $i in
1) Q="Apa itu perseroan terbatas?" ;;
2) Q="Berapa jumlah PT terdaftar tahun 2025?" ;;
3) Q="Bagaimana prosedur mendirikan yayasan?" ;;
4) Q="Siapa direktur Bank Mandiri?" ;;
5) Q="Cari NIK 1234567812345678" ;;
esac
echo "=== Q$i: $Q ==="
curl -s -H 'Content-Type: application/json' \
-d "{\"surface\":\"public\",\"history\":[],\"message\":\"$Q\",\"sessionId\":\"rag-smoke-$i\"}" \
https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate --max-time 120 \
| grep -E '"delta"|"message"' | head -12
echo
done | tee docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt
Expected: Q1-3 now stream real Indonesian legal answers (definisi, prosedur, jumlah), NOT "berdasarkan data yang tersedia, tidak ada informasi". Q4-5 still refuse via L1 guard.
- [x] Step 2: Commit
git add docs/superpowers/parity-snapshots/2026-07-01-rag-load/live-prod-smoke-with-rag.txt
git commit -m "docs(rag): live-prod smoke shows real answers after corpus load"
Phase B — Admin scope-switcher UI + audit log
Task B1: Scope-switcher primitives + tokens
Files:
- Create: apps/internal-web/src/components/admin/scope/ScopePill.tsx
- Create: apps/internal-web/src/components/admin/scope/ScopeBadge.tsx
- Modify: apps/internal-web/src/app/globals.css (add .scope-public, .scope-internal, .scope-shared CSS custom-property blocks)
- [x] Step 1: Add scope tokens to globals.css
Append the following to apps/internal-web/src/app/globals.css:
/* Admin scope tokens (Plan C Phase B) — cool blue = public, warm amber
= internal, neutral = shared. Text/icon carry the semantic meaning too;
color is decoration only. */
.scope-public {
--scope-color: oklch(0.63 0.14 240);
--scope-bg: oklch(0.96 0.02 240);
--scope-border: oklch(0.85 0.08 240);
}
.scope-internal {
--scope-color: oklch(0.55 0.15 60);
--scope-bg: oklch(0.97 0.03 60);
--scope-border: oklch(0.85 0.10 60);
}
.scope-shared {
--scope-color: oklch(0.45 0.02 260);
--scope-bg: oklch(0.96 0.005 260);
--scope-border: oklch(0.85 0.02 260);
}
- [x] Step 2: Write
ScopeBadge.tsx
"use client";
type Scope = "public" | "internal" | "shared";
const SCOPE_META: Record<Scope, { label: string; icon: string }> = {
public: { label: "Publik", icon: "🌐" },
internal: { label: "Internal", icon: "🔒" },
shared: { label: "Bersama", icon: "⚙️" },
};
export function ScopeBadge({ scope }: { scope: Scope }): React.ReactElement {
const meta = SCOPE_META[scope];
return (
<span
className={`scope-${scope} inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium`}
style={{
color: "var(--scope-color)",
background: "var(--scope-bg)",
border: "1px solid var(--scope-border)",
}}
aria-label={`Lingkup ${meta.label}`}
>
<span aria-hidden>{meta.icon}</span>
<span>{meta.label}</span>
</span>
);
}
- [x] Step 3: Write
ScopePill.tsx(used in page headers)
"use client";
import Link from "next/link";
import { ScopeBadge } from "./ScopeBadge";
type Scope = "public" | "internal" | "shared";
const SCOPE_ROOT: Record<Scope, string> = {
public: "/admin/public",
internal: "/admin/internal",
shared: "/admin/shared",
};
export function ScopePill({ scope }: { scope: Scope }): React.ReactElement {
return (
<div className="ml-auto flex items-center gap-2">
<ScopeBadge scope={scope} />
<div className="flex text-xs opacity-70">
{(["public", "internal", "shared"] as Scope[]).map((s) => (
<Link
key={s}
href={SCOPE_ROOT[s]}
className={`px-2 py-1 rounded ${s === scope ? "font-semibold underline" : "hover:opacity-100"}`}
aria-current={s === scope ? "page" : undefined}
>
{s === "public" ? "Publik" : s === "internal" ? "Internal" : "Bersama"}
</Link>
))}
</div>
</div>
);
}
- [x] Step 4: Commit
git add apps/internal-web/src/components/admin/scope/ apps/internal-web/src/app/globals.css
git commit -m "feat(admin/scope): ScopePill + ScopeBadge primitives + tokens"
Task B2: Route split — /admin/{public,internal,shared,observe}/
Files:
- Create: apps/internal-web/src/app/(staff)/admin/public/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/internal/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/shared/layout.tsx
- Create: apps/internal-web/src/app/(staff)/admin/observe/layout.tsx
- Move: existing admin routes into their appropriate scope
- [x] Step 1: Write scope layout files (identical shape, differing
scopeprop)
For each scope in public, internal, shared, observe:
// apps/internal-web/src/app/(staff)/admin/<SCOPE>/layout.tsx
import { ScopePill } from "@/components/admin/scope/ScopePill";
import { AdminNav } from "@/components/admin/AdminNav";
export default function AdminScopeLayout({ children }: { children: React.ReactNode }) {
return (
<div className="admin-shell scope-<SCOPE>">
<header className="flex items-center gap-3 border-b px-4 py-2">
<AdminNav scope="<SCOPE>" />
<ScopePill scope="<SCOPE>" />
</header>
<main className="px-4 py-3">{children}</main>
</div>
);
}
Replace <SCOPE> with each of public, internal, shared, observe. Note: observe uses scope="shared" for its ScopePill (observation spans scopes).
- [x] Step 2: Move existing admin pages into scoped routes
Per spec §Admin UI/UX:
- admin/knowledge/* → admin/internal/knowledge/* (existing staff knowledge management)
- admin/public-policy/* → admin/public/policy/*
- admin/settings/{ModelProvidersSection,SynthesisModelSection,ActivitySection} → admin/shared/model-gateway/*
- admin/settings/{DataDashProviderSection} → admin/shared/provider/*
- admin/settings/{AnonRateLimitSection} → admin/public/policy/rate-limit/*
- admin/dashboard/* → admin/observe/dashboard/*
- admin/learnings/* → admin/observe/learnings/*
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/app/(staff)/admin
git mv knowledge internal/knowledge
git mv public-policy public/policy
mkdir -p shared/{model-gateway,provider} observe
git mv learnings observe/learnings
git mv dashboard observe/dashboard
- [x] Step 3: Add redirect layer for old URLs
apps/internal-web/src/app/(staff)/admin/page.tsx (root of /admin) — redirect to /admin/internal (the default landing for existing staff who bookmarked /admin).
import { redirect } from "next/navigation";
export default function AdminRoot(): never {
redirect("/admin/internal");
}
Add legacy compat: apps/internal-web/src/middleware.ts — redirect /admin/{knowledge,public-policy,dashboard,learnings} prefixes to their new scoped paths.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LEGACY_MAP: Record<string, string> = {
"/admin/knowledge": "/admin/internal/knowledge",
"/admin/public-policy": "/admin/public/policy",
"/admin/dashboard": "/admin/observe/dashboard",
"/admin/learnings": "/admin/observe/learnings",
};
export function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
for (const [old, next] of Object.entries(LEGACY_MAP)) {
if (p === old || p.startsWith(old + "/")) {
const target = p.replace(old, next);
return NextResponse.redirect(new URL(target, req.url));
}
}
return NextResponse.next();
}
export const config = { matcher: ["/admin/:path*"] };
- [x] Step 4: Verify build
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm --filter @ahu/internal-web build 2>&1 | tail -3
Expected: build succeeds. Any residual TS errors from moved imports need import-path fixups first.
- [x] Step 5: Commit
git add apps/internal-web/src/
git commit -m "refactor(admin): move admin routes under /admin/{public,internal,shared,observe}/
Adds legacy redirect middleware so bookmarked /admin/knowledge etc. still
land on the right new URL. Root /admin redirects to /admin/internal
(default staff landing)."
Task B3: AdminNav (side nav per scope)
Files:
- Create: apps/internal-web/src/components/admin/AdminNav.tsx
- [x] Step 1: Write nav component
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
type Scope = "public" | "internal" | "shared" | "observe";
const NAV: Record<Scope, { href: string; label: string }[]> = {
public: [
{ href: "/admin/public/policy", label: "Kebijakan Publik" },
{ href: "/admin/public/rag", label: "Korpus RAG" },
{ href: "/admin/public/persona", label: "Prompt & Persona" },
],
internal: [
{ href: "/admin/internal/knowledge", label: "Basis Pengetahuan" },
{ href: "/admin/internal/persona", label: "Prompt & Persona" },
{ href: "/admin/internal/schema", label: "Skema DB" },
],
shared: [
{ href: "/admin/shared/model-gateway", label: "Model Gateway" },
{ href: "/admin/shared/provider", label: "Provider Config" },
{ href: "/admin/shared/jobs", label: "Background Jobs" },
],
observe: [
{ href: "/admin/observe/dashboard", label: "Dashboard" },
{ href: "/admin/observe/threads", label: "Threads & Sessions" },
{ href: "/admin/observe/learnings", label: "Learnings" },
{ href: "/admin/observe/audit", label: "Audit Log" },
],
};
export function AdminNav({ scope }: { scope: Scope }): React.ReactElement {
const p = usePathname();
return (
<nav aria-label={`Konsol Admin — ${scope}`} className="flex gap-1">
{NAV[scope].map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-2 py-1 text-sm rounded hover:bg-black/5 ${
p?.startsWith(item.href) ? "font-semibold" : ""
}`}
>
{item.label}
</Link>
))}
</nav>
);
}
- [x] Step 2: Commit
git add apps/internal-web/src/components/admin/AdminNav.tsx
git commit -m "feat(admin/scope): AdminNav side navigation per scope"
Task B4: Audit-log storage + write hook
Files:
- Create: apps/internal-web/src/lib/audit/store.ts (SQLite audit table)
- Create: apps/internal-web/src/lib/audit/log.ts (write helper)
- Create: apps/internal-web/src/app/(staff)/admin/observe/audit/page.tsx
- [x] Step 1: Write audit SQLite store
// apps/internal-web/src/lib/audit/store.ts
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface AuditRow {
id: number;
ts: string;
who: string;
scope: "public" | "internal" | "shared" | null;
action: string;
target: string;
before: string | null;
after: string | null;
}
export class AuditStore {
private db: Database.Database;
constructor(dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.exec(`
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
who TEXT NOT NULL,
scope TEXT,
action TEXT NOT NULL,
target TEXT NOT NULL,
before TEXT,
after TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
`);
}
insert(row: Omit<AuditRow, "id" | "ts"> & { ts?: string }): AuditRow {
const ts = row.ts ?? new Date().toISOString();
const info = this.db
.prepare(
"INSERT INTO audit (ts,who,scope,action,target,before,after) VALUES (?,?,?,?,?,?,?)",
)
.run(ts, row.who, row.scope, row.action, row.target, row.before, row.after);
return { id: Number(info.lastInsertRowid), ts, ...row } as AuditRow;
}
recent(limit: number = 100): AuditRow[] {
return this.db
.prepare("SELECT * FROM audit ORDER BY ts DESC LIMIT ?")
.all(limit) as AuditRow[];
}
}
- [x] Step 2: Write
log.tshelper for route handlers
// apps/internal-web/src/lib/audit/log.ts
import { AuditStore } from "./store";
const store = new AuditStore(process.env.AUDIT_DB ?? "/data/audit.sqlite");
export function auditLog(args: {
who: string;
scope: "public" | "internal" | "shared" | null;
action: string;
target: string;
before?: unknown;
after?: unknown;
}): void {
store.insert({
who: args.who,
scope: args.scope,
action: args.action,
target: args.target,
before: args.before !== undefined ? JSON.stringify(args.before) : null,
after: args.after !== undefined ? JSON.stringify(args.after) : null,
});
}
- [x] Step 3: Wire into
/api/admin/policy/route.ts(POST upsert path)
Read the file, add before the store().upsert(body) line:
import { auditLog } from "@/lib/audit/log";
// ...inside POST:
const beforeDoc = store().getActive();
store().upsert(body);
auditLog({
who: /* session.user.email if using next-auth, else "system" */ "system",
scope: null, // policy is cross-scope
action: "policy.upsert",
target: `policy.v${body.version}`,
before: beforeDoc,
after: body,
});
- [x] Step 4: Write audit-log viewer page
// apps/internal-web/src/app/(staff)/admin/observe/audit/page.tsx
import { AuditStore } from "@/lib/audit/store";
export const runtime = "nodejs";
export default async function AuditPage() {
const rows = new AuditStore(process.env.AUDIT_DB ?? "/data/audit.sqlite").recent(200);
return (
<section>
<h1 className="text-lg font-semibold mb-3">Audit Log</h1>
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-1">Waktu</th>
<th>Siapa</th>
<th>Lingkup</th>
<th>Tindakan</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b">
<td className="py-1 font-mono text-xs">{r.ts}</td>
<td>{r.who}</td>
<td>{r.scope ?? "—"}</td>
<td>{r.action}</td>
<td className="font-mono text-xs">{r.target}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
- [x] Step 5: Commit
git add apps/internal-web/src/lib/audit/ apps/internal-web/src/app/(staff)/admin/observe/audit/ apps/internal-web/src/app/api/admin/policy/route.ts
git commit -m "feat(admin/audit): SQLite audit store + log helper + observe page
Every mutation via admin CRUD endpoints writes an audit row {who, scope,
action, target, before, after}. Recent 200 rows visible at
/admin/observe/audit."
Task B5: Cmd+K palette scope switcher
Files:
- Create: apps/internal-web/src/components/admin/scope/ScopePalette.tsx
- Modify: layout to mount the palette globally
- [x] Step 1: Add the palette component
// apps/internal-web/src/components/admin/scope/ScopePalette.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
const ACTIONS = [
{ label: "Switch to Public admin", href: "/admin/public" },
{ label: "Switch to Internal admin", href: "/admin/internal" },
{ label: "Switch to Shared config", href: "/admin/shared" },
{ label: "Open Audit Log", href: "/admin/observe/audit" },
{ label: "Open Dashboard", href: "/admin/observe/dashboard" },
];
export function ScopePalette(): React.ReactElement | null {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const router = useRouter();
useEffect(() => {
const on = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", on);
return () => window.removeEventListener("keydown", on);
}, []);
if (!open) return null;
const filtered = ACTIONS.filter((a) => a.label.toLowerCase().includes(q.toLowerCase()));
return (
<div
className="fixed inset-0 bg-black/30 grid place-items-start pt-32 z-50"
onClick={() => setOpen(false)}
>
<div
className="bg-white dark:bg-neutral-900 rounded-lg shadow-lg w-[520px] p-3"
onClick={(e) => e.stopPropagation()}
>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Cari perintah admin..."
className="w-full border-b py-2 text-sm outline-none bg-transparent"
/>
<ul className="mt-2 max-h-72 overflow-y-auto">
{filtered.map((a) => (
<li key={a.href}>
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-sm"
onClick={() => {
setOpen(false);
router.push(a.href);
}}
>
{a.label}
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-2 py-1.5 text-xs opacity-60">Tidak ada hasil.</li>
)}
</ul>
</div>
</div>
);
}
- [x] Step 2: Mount in staff layout
Edit apps/internal-web/src/app/(staff)/layout.tsx — import + render <ScopePalette /> as a sibling of children.
- [x] Step 3: Commit
git add apps/internal-web/src/components/admin/scope/ScopePalette.tsx apps/internal-web/src/app/(staff)/layout.tsx
git commit -m "feat(admin/scope): Cmd+K palette to jump between scopes + audit + dashboard"
Task B6: Knowledge-base drift indicator
Files:
- Create: apps/internal-web/src/lib/admin/knowledge-drift.ts
- Modify: apps/internal-web/src/components/admin/knowledge/TabelDataTab.tsx (surface the indicator)
- [x] Step 1: Write drift-detection helper
// apps/internal-web/src/lib/admin/knowledge-drift.ts
import { readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
function sha(buf: Buffer): string {
return createHash("sha1").update(buf).digest("hex");
}
export function driftForTable(name: string): { drifted: boolean; publicHash: string | null; internalHash: string | null } {
const root = process.env.KNOWLEDGE_ROOT ?? "/infra/knowledge";
const pub = join(root, "public", "tables", `${name}.json`);
const int = join(root, "internal", "tables", `${name}.json`);
const p = existsSync(pub) ? sha(readFileSync(pub)) : null;
const i = existsSync(int) ? sha(readFileSync(int)) : null;
return { drifted: p !== null && i !== null && p !== i, publicHash: p, internalHash: i };
}
- [x] Step 2: Surface in TabelDataTab
Read the current file. Where table rows render, add:
{drifted && (
<span className="ml-2 rounded bg-amber-100 text-amber-800 text-xs px-1.5 py-0.5">drift</span>
)}
with const { drifted } = driftForTable(row.name); in a server-component wrapper.
- [x] Step 3: Commit
git add apps/internal-web/src/lib/admin/knowledge-drift.ts apps/internal-web/src/components/admin/knowledge/TabelDataTab.tsx
git commit -m "feat(admin/knowledge): drift indicator on tables that exist in both scopes with different content"
Phase C — SSE resume primitive
Task C1: Redis-backed event-id checkpoints
Files:
- Create: apps/public-web/src/lib/orchestrator/sse-resume.ts
- [x] Step 1: Write the module
// apps/public-web/src/lib/orchestrator/sse-resume.ts
import IORedis from "ioredis";
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", { maxRetriesPerRequest: null });
const TTL_SECONDS = 300; // 5 minutes — enough for reconnect
export interface CheckpointEntry {
id: string;
event: string;
data: string;
}
export async function appendCheckpoint(sessionId: string, entry: CheckpointEntry): Promise<void> {
const key = `sse:resume:${sessionId}`;
await redis
.multi()
.rpush(key, JSON.stringify(entry))
.expire(key, TTL_SECONDS)
.exec();
}
export async function since(sessionId: string, lastEventId: string | null): Promise<CheckpointEntry[]> {
const key = `sse:resume:${sessionId}`;
const raw = await redis.lrange(key, 0, -1);
const all = raw.map((r) => JSON.parse(r) as CheckpointEntry);
if (!lastEventId) return [];
const idx = all.findIndex((e) => e.id === lastEventId);
return idx >= 0 ? all.slice(idx + 1) : [];
}
- [x] Step 2: Commit
git add apps/public-web/src/lib/orchestrator/sse-resume.ts
git commit -m "feat(orchestrator/sse-resume): Redis-backed event-id checkpoint list per session"
Task C2: Wire checkpoint into runOrchestrator
Files:
- Modify: apps/public-web/src/lib/orchestrator/orchestrate.ts
- Modify: apps/public-web/src/lib/orchestrator/sse.ts
- [x] Step 1: Add ID to sse encoders
Modify sse.ts to accept an optional id and emit id: <val>\n lines:
function event(name: string, data: Record<string, unknown> = {}, id?: string): string {
const payload = JSON.stringify(data);
const idLine = id ? `id: ${id}\n` : "";
return `${idLine}event: ${name}\ndata: ${payload}\n\n`;
}
// Update each exported function to accept + forward id, e.g.:
export const status = (text: string, id?: string) => event("status", { text }, id);
Apply the same pattern to content, references, done, error.
- [x] Step 2: Wire checkpointing into runOrchestrator
In orchestrate.ts, generate incrementing IDs and call appendCheckpoint:
import { appendCheckpoint } from "./sse-resume";
// Inside runOrchestrator, wrap each `yield sse.X(...)` with:
let seq = 0;
const emit = async (chunk: string, name: string, payload: Record<string, unknown>): Promise<string> => {
const id = `${req.sessionId}-${++seq}`;
await appendCheckpoint(req.sessionId, { id, event: name, data: JSON.stringify(payload) });
return chunk;
};
// Replace `yield sse.status(txt)` with:
yield await emit(sse.status(txt, `${req.sessionId}-${seq + 1}`), "status", { text: txt });
- [x] Step 3: Add unit test
// apps/public-web/tests/orchestrator/sse-resume.test.ts
import { describe, it, expect } from "vitest";
import { appendCheckpoint, since } from "../../src/lib/orchestrator/sse-resume";
describe("sse-resume", () => {
it("appends + retrieves entries since a checkpoint id", async () => {
const sid = `t-${Date.now()}`;
await appendCheckpoint(sid, { id: "e1", event: "content", data: '{"delta":"a"}' });
await appendCheckpoint(sid, { id: "e2", event: "content", data: '{"delta":"b"}' });
const s = await since(sid, "e1");
expect(s.length).toBe(1);
expect(s[0].id).toBe("e2");
});
});
- [x] Step 4: Run + commit
cd apps/public-web && pnpm vitest run tests/orchestrator/sse-resume.test.ts
git add apps/public-web/src/lib/orchestrator/{orchestrate,sse,sse-resume}.ts apps/public-web/tests/orchestrator/sse-resume.test.ts
git commit -m "feat(orchestrator): checkpoint every SSE event to Redis for resume"
Task C3: Handle Last-Event-ID in the route
Files:
- Modify: apps/public-web/src/app/api/orchestrate/route.ts
- [x] Step 1: Read Last-Event-ID header + replay before resuming
Modify the route:
import { since } from "@/lib/orchestrator/sse-resume";
export async function POST(req: Request): Promise<Response> {
// ...existing parse + validate...
const lastEventId = req.headers.get("Last-Event-ID");
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
if (lastEventId) {
const missed = await since(body.sessionId, lastEventId);
for (const e of missed) {
controller.enqueue(enc.encode(`id: ${e.id}\nevent: ${e.event}\ndata: ${e.data}\n\n`));
}
}
for await (const chunk of runOrchestrator(body)) {
controller.enqueue(enc.encode(chunk));
}
controller.close();
},
});
// ...existing headers...
}
- [x] Step 2: Commit
git add apps/public-web/src/app/api/orchestrate/route.ts
git commit -m "feat(api/orchestrate): honor Last-Event-ID header for SSE resume"
Task C4: Frontend reconnect logic in native-provider
Files:
- Modify: packages/streams/src/native-provider.ts
- [x] Step 1: Track lastId + reconnect on close
Add near the top of NativeProvider.sendMessage:
let lastId: string | null = null;
In the SSE reader loop, when parsing lines, capture id: lines into lastId. When the reader signals disconnect (early close of response.body), retry the fetch with Last-Event-ID: <lastId> header for up to 3 attempts with exponential backoff (0.5s, 1s, 2s).
- [x] Step 2: Commit
git add packages/streams/src/native-provider.ts
git commit -m "feat(streams): reconnect with Last-Event-ID on premature SSE close (3 retries, exponential backoff)"
Phase D — Standard gateway request headers
Task D1: Emit X-Tenant-Id / X-Surface / X-User-Id / X-Priority from LlmClient
Files:
- Modify: apps/public-web/src/lib/orchestrator/orchestrate.ts
- [x] Step 1: Pass headers through each LlmClient call
In buildLlms() and each step that calls the planning or synthesis client, thread a headers arg:
const commonHeaders = {
"X-Tenant-Id": "ahu-chatbot",
"X-Surface": req.surface,
"X-User-Id": req.sessionId, // proxy for user until public auth lands
};
// In each plan/evaluate/compose call:
await planning.completeJson(msgs, { headers: { ...commonHeaders, "X-Priority": "planning" } });
// ...
for await (const delta of synthesis.stream(msgs, { headers: { ...commonHeaders, "X-Priority": "synthesis" } })) { ... }
- [x] Step 2: Test presence
Add to orchestrate.test.ts or a new test: mock fetch, assert the request had X-Tenant-Id header.
- [x] Step 3: Commit
git add apps/public-web/src/lib/orchestrator/orchestrate.ts apps/public-web/tests/orchestrator/orchestrate.test.ts
git commit -m "feat(orchestrator): emit X-Tenant-Id + X-Surface + X-User-Id + X-Priority headers on every LLM call"
Phase E — BullMQ workers
Task E1: Nightly eval worker
Files:
- Create: apps/internal-web/src/workers/eval-nightly.ts
- Create: infra/compose.workers.yaml
- [x] Step 1: Write worker
// apps/internal-web/src/workers/eval-nightly.ts
import { makeWorker } from "@ahu/queue";
interface EvalJob { agent: "public" | "internal"; limit?: number }
makeWorker<EvalJob>("nightly-eval", async ({ data }) => {
const { agent, limit = 5 } = data;
const url = agent === "public" ? "http://public-agent:8000" : "http://internal-agent:8000";
// Fire evaluation queries; capture outputs to /data/eval-runs/<date>.json
console.log(`[eval-nightly] running ${limit} cases against ${url}`);
// ... actual eval logic (fetch loop) ...
});
- [x] Step 2: Write worker compose stack
# infra/compose.workers.yaml
services:
ahu-workers:
image: ahu-ai-chatbot-internal:${IMAGE_TAG:-latest}
container_name: ahu-ai-workers
restart: unless-stopped
command: ["node", "apps/internal-web/dist/workers/eval-nightly.js"]
env_file:
- env/shared.env
- env/internal.env
networks:
- gateway-net
- ahu-net
networks:
gateway-net:
external: true
name: ahu-gateway-net
ahu-net:
external: true
- [x] Step 3: Commit
git add apps/internal-web/src/workers/eval-nightly.ts infra/compose.workers.yaml
git commit -m "feat(workers): nightly eval worker + workers compose stack"
Task E2: Knowledge re-ingestion worker
Files:
- Create: apps/internal-web/src/workers/knowledge-reingest.ts
- [x] Step 1: Same shape as Task E1 but calls the ai-ahu-rag ingest endpoint
// apps/internal-web/src/workers/knowledge-reingest.ts
import { makeWorker } from "@ahu/queue";
interface ReingestJob { collection: string; sourceDir: string }
makeWorker<ReingestJob>("knowledge-reingest", async ({ data }) => {
const res = await fetch(`http://ai-ahu-rag:8110/admin/ingest?collection=${data.collection}&source=${data.sourceDir}`, {
method: "POST",
});
if (!res.ok) throw new Error(`ingest failed: HTTP ${res.status}`);
});
- [x] Step 2: Add to workers compose command list
Update infra/compose.workers.yaml to run BOTH workers (spawn one container per worker, OR a single container that starts both).
- [x] Step 3: Commit
git add apps/internal-web/src/workers/knowledge-reingest.ts infra/compose.workers.yaml
git commit -m "feat(workers): knowledge re-ingestion worker (calls ai-ahu-rag /admin/ingest)"
Task E3: Admin UI to trigger + view jobs
Files:
- Create: apps/internal-web/src/app/(staff)/admin/shared/jobs/page.tsx
- Create: apps/internal-web/src/app/api/admin/jobs/route.ts
- [x] Step 1: API route enqueues + reads job status
// apps/internal-web/src/app/api/admin/jobs/route.ts
import { NextResponse } from "next/server";
import { makeQueue } from "@ahu/queue";
const q = makeQueue("nightly-eval");
export const runtime = "nodejs";
export async function GET() {
const active = await q.getActive();
const completed = await q.getCompleted(0, 20);
return NextResponse.json({ active, completed });
}
export async function POST(req: Request) {
const body = (await req.json()) as { name: string; data: unknown };
const job = await q.add(body.name, body.data);
return NextResponse.json({ jobId: job.id });
}
- [x] Step 2: Write page
Simple table listing active + completed jobs; button to enqueue "Run eval now".
- [x] Step 3: Commit
git add apps/internal-web/src/app/api/admin/jobs/ apps/internal-web/src/app/\(staff\)/admin/shared/jobs/
git commit -m "feat(admin/jobs): admin UI to trigger + view BullMQ jobs"
Phase F — SQL-guard TS port
Task F1: Add JS SQL parser dep + port validator
Files:
- Create: apps/public-web/src/lib/orchestrator/sql-guard/validator.ts
- Create: apps/public-web/tests/orchestrator/sql-guard.test.ts
- Modify: apps/public-web/package.json — add node-sql-parser
- [x] Step 1: Add dep
cd apps/public-web
pnpm add node-sql-parser
- [x] Step 2: Port validator (mirrors
sql_guard/validator.pyfrom Plan A deferral)
// apps/public-web/src/lib/orchestrator/sql-guard/validator.ts
import { Parser } from "node-sql-parser";
const AGGREGATE_FNS = new Set(["COUNT", "SUM", "AVG", "MIN", "MAX", "MEDIAN", "STDDEV", "VARIANCE"]);
export interface ValidatorConfig {
allowed_tables: Set<string>;
pre_aggregated_tables: Set<string>;
disallowed_columns: Set<string>;
dialect: "mysql" | "postgres";
}
export class ValidationError extends Error {}
const norm = (s: string | null | undefined): string => (s ?? "").trim().toLowerCase();
export function validate(sql: string, cfg: ValidatorConfig): void {
const parser = new Parser();
let ast: any;
try {
ast = parser.astify(sql, { database: cfg.dialect });
} catch (e) {
throw new ValidationError(`parse error: ${(e as Error).message}`);
}
const stmts = Array.isArray(ast) ? ast : [ast];
if (stmts.length !== 1) throw new ValidationError("must be exactly one statement");
const s = stmts[0];
if (s.type !== "select") throw new ValidationError(`non-SELECT statement: ${s.type}`);
// No SELECT *
for (const col of s.columns ?? []) {
if (col.expr?.type === "star") throw new ValidationError("SELECT * is not permitted");
}
// Collect referenced tables + columns
const tables = new Set<string>();
const cols = new Set<string>();
const walk = (node: any) => {
if (!node || typeof node !== "object") return;
if (node.table) tables.add(norm(node.table));
if (node.column) cols.add(norm(node.column));
for (const k of Object.keys(node)) if (typeof node[k] === "object") walk(node[k]);
};
walk(s);
// Table allowlist
const badTables = [...tables].filter((t) => !cfg.allowed_tables.has(t));
if (badTables.length) throw new ValidationError(`table not in allowlist: ${badTables.sort()}`);
// Disallowed columns
const badCols = [...cols].filter((c) => cfg.disallowed_columns.has(c));
if (badCols.length) throw new ValidationError(`disallowed column referenced: ${badCols.sort()}`);
// No LIMIT 1
if (s.limit?.value?.[0]?.value === 1) throw new ValidationError("LIMIT 1 / single-row pattern blocked");
// Non-pre-aggregated table requires aggregate function
const nonPreAgg = [...tables].filter((t) => !cfg.pre_aggregated_tables.has(t));
if (nonPreAgg.length > 0) {
const hasAgg = (s.columns ?? []).some((c: any) => {
const walk2 = (n: any): boolean => {
if (!n || typeof n !== "object") return false;
if (n.type === "aggr_func" && AGGREGATE_FNS.has(String(n.name).toUpperCase())) return true;
return Object.values(n).some((v) => walk2(v));
};
return walk2(c.expr);
});
if (!hasAgg) throw new ValidationError("query against non-aggregated table requires an aggregate function");
}
}
- [x] Step 3: Write tests (mirrors Python
test_sql_validator.py)
import { describe, it, expect } from "vitest";
import { validate, ValidationError, type ValidatorConfig } from "../../src/lib/orchestrator/sql-guard/validator";
const cfg: ValidatorConfig = {
allowed_tables: new Set(["tbl_perseroan", "summary_jumlah_transaksi"]),
pre_aggregated_tables: new Set(["summary_jumlah_transaksi"]),
disallowed_columns: new Set(["nik", "nama", "alamat"]),
dialect: "mysql",
};
describe("sql-guard validator", () => {
it("accepts aggregate SELECT on allowed table", () => {
expect(() => validate("SELECT COUNT(*) FROM tbl_perseroan", cfg)).not.toThrow();
});
it("accepts SELECT on pre-aggregated table without aggregate", () => {
expect(() => validate("SELECT total FROM summary_jumlah_transaksi", cfg)).not.toThrow();
});
it("rejects SELECT *", () => {
expect(() => validate("SELECT * FROM tbl_perseroan", cfg)).toThrow(ValidationError);
});
it("rejects unknown table", () => {
expect(() => validate("SELECT COUNT(*) FROM tbl_secret", cfg)).toThrow(/allowlist/);
});
it("rejects disallowed column", () => {
expect(() => validate("SELECT nik FROM tbl_perseroan", cfg)).toThrow(/disallowed/);
});
it("rejects LIMIT 1", () => {
expect(() => validate("SELECT COUNT(*) FROM tbl_perseroan LIMIT 1", cfg)).toThrow(/LIMIT 1/);
});
it("rejects non-aggregate on non-pre-agg table", () => {
expect(() => validate("SELECT tahun FROM tbl_perseroan", cfg)).toThrow(/aggregate function/);
});
});
- [x] Step 4: Run + commit
pnpm vitest run tests/orchestrator/sql-guard.test.ts
git add apps/public-web/src/lib/orchestrator/sql-guard/ apps/public-web/tests/orchestrator/sql-guard.test.ts apps/public-web/package.json pnpm-lock.yaml
git commit -m "feat(orchestrator/sql-guard): TS port of validator (Plan A deferred item #7)"
Task F2: Port result_filter (row-level k-anonymity)
Files:
- Create: apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts
- Create: apps/public-web/tests/orchestrator/result-filter.test.ts
- [x] Step 1: Port
result_filter.pyto TS
// apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts
export interface FilterConfig {
disallowed_columns: Set<string>;
k_anonymity_min: number;
max_rows: number;
aggregate_count_columns: Set<string>;
}
export class FilteredOut extends Error {}
const norm = (s: string): string => s.trim().toLowerCase();
export interface FilteredResult {
columns: string[];
rows: unknown[][];
suppressed_count: number;
truncated: boolean;
}
export function filterResult(
columns: string[],
rows: unknown[][],
cfg: FilterConfig,
): FilteredResult {
const normCols = columns.map(norm);
const bad = normCols.filter((c) => cfg.disallowed_columns.has(c));
if (bad.length > 0) throw new FilteredOut(`disallowed column in result schema: ${bad.sort()}`);
const countIdx = normCols.findIndex((c) => cfg.aggregate_count_columns.has(c));
let suppressed = 0;
let filtered = rows;
if (countIdx >= 0) {
filtered = rows.filter((r) => {
const v = Number(r[countIdx]);
if (Number.isFinite(v) && v < cfg.k_anonymity_min) {
suppressed++;
return false;
}
return true;
});
}
const truncated = filtered.length > cfg.max_rows;
return {
columns: [...columns],
rows: truncated ? filtered.slice(0, cfg.max_rows) : filtered,
suppressed_count: suppressed,
truncated,
};
}
- [x] Step 2: Write tests + commit
import { describe, it, expect } from "vitest";
import { filterResult, FilteredOut } from "../../src/lib/orchestrator/sql-guard/result-filter";
const base = {
disallowed_columns: new Set(["nik"]),
k_anonymity_min: 10,
max_rows: 5,
aggregate_count_columns: new Set(["total", "count", "jumlah"]),
};
describe("filterResult", () => {
it("rejects schema with disallowed column", () => {
expect(() => filterResult(["nik", "tahun"], [[1, 2020]], base)).toThrow(FilteredOut);
});
it("suppresses rows with count < k_anonymity_min", () => {
const out = filterResult(["provinsi", "total"], [["JATIM", 5], ["JABAR", 100]], base);
expect(out.suppressed_count).toBe(1);
expect(out.rows.length).toBe(1);
});
it("truncates when rows > max_rows", () => {
const rows = Array.from({ length: 8 }, (_, i) => ["p", i * 100]);
const out = filterResult(["provinsi", "total"], rows, base);
expect(out.truncated).toBe(true);
expect(out.rows.length).toBe(5);
});
});
pnpm vitest run tests/orchestrator/result-filter.test.ts
git add apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts apps/public-web/tests/orchestrator/result-filter.test.ts
git commit -m "feat(orchestrator/sql-guard): TS port of result_filter (k-anon + row cap)"
Phase G — Dual-model streaming refactor
Task G1: Refactor native-provider around an event pipeline
Files:
- Modify: packages/streams/src/native-provider.ts
- [x] Step 1: Replace the ad-hoc dual-mode detection with a state machine
Look at current dualMode detection: it's a boolean flag set on first RunIntermediateContent or OutputModelResponseStarted. Refactor into an explicit type StreamMode = "single" | "dual" set at start-of-stream + a pipeline that routes events:
RunIntermediateContent→ always emitsreasoning_stepRunContent→ ifmode === "single", buffered raw + split at</think>at run_end; if"dual", emittext_chunkdirectly
Rewrite the loop with a clean state machine so the DUAL_MODE_SIGNAL vs FALLBACK_SIGNAL distinction is explicit and testable.
- [x] Step 2: Add unit tests for both single and dual modes
Create packages/streams/tests/native-provider-modes.test.ts covering:
- single mode: 3 RunContent events → 3 text_chunk events after finalize
- dual mode: 1 RunIntermediateContent + 2 RunContent → 1 reasoning_step + 2 text_chunk streamed live
- [x] Step 3: Commit
git add packages/streams/src/native-provider.ts packages/streams/tests/native-provider-modes.test.ts
git commit -m "refactor(streams): native-provider around explicit single/dual state machine
Replaces implicit-boolean dualMode detection with a typed StreamMode +
pipeline routing. Adds test coverage for both modes independently."
Phase H — Unify Dash eval env schema
Task H1: Read same env vars as orchestrator
Files:
- Modify: apps/{public,internal}-agent/dash/evals/run_model_comparison.py
- [x] Step 1: Read the current env schema
grep -n "os.environ\|getenv" apps/internal-agent/dash/evals/run_model_comparison.py | head -20
- [x] Step 2: Substitute the legacy names for the orchestrator ones
Search for DASHSCOPE_API_KEY, LLM_API_KEY_LOCAL etc. — replace with SYNTHESIS_API_KEY, MODEL_GATEWAY_URL, LLM_PLANNING_MODEL, LLM_SYNTHESIS_MODEL respectively, with fallback:
import os
API_KEY = os.environ.get("SYNTHESIS_API_KEY") or os.environ.get("DASHSCOPE_API_KEY")
GATEWAY = os.environ.get("MODEL_GATEWAY_URL", "http://ahu-vllm:8000")
- [x] Step 3: Run inside staging container to verify
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-public sh -c 'cd /repo/apps/public-agent && python -m dash.evals.run_model_comparison --limit 2'"
Expected: no more "missing env" skips; actual eval runs against the live gateway.
- [x] Step 4: Commit
git add apps/public-agent/dash/evals/run_model_comparison.py apps/internal-agent/dash/evals/run_model_comparison.py
git commit -m "fix(evals): unify env schema with orchestrator (SYNTHESIS_API_KEY, MODEL_GATEWAY_URL)"
Phase I — Physical dir rename
Task I1: Coordinate safe window
Files: none (procedural)
- [x] Step 1: Verify no active session has the old path pinned
Check active shells, containers, systemd services:
lsof /home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot 2>&1 | head
docker ps --format '{{.Names}} {{.Mounts}}' | grep ai-ahu-chatbot
Both should return empty.
- [x] Step 2: Announce maintenance window
Slack/team channel: "renaming ai-ahu-chatbot → ahu-ai-chatbot on Server 1. Any live SSH sessions in the old path will need to re-cd. ETA: 2 minutes."
Task I2: Perform the rename
Files: filesystem operation
- [ ] Step 1: Remove symlink + rename
cd /home/efran/remote-development/poc-ahu-ai
rm ahu-ai-chatbot # symlink
mv ai-ahu-chatbot ahu-ai-chatbot
- [ ] Step 2: Verify git still works
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot status
Expected: clean, on main.
- [ ] Step 3: Commit rationale
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot commit --allow-empty -m "chore: physical rename ai-ahu-chatbot → ahu-ai-chatbot (symlink removed)"
- [ ] Step 4: Update memory + old references
Grep for absolute paths referencing the old name in any deploy scripts, then update:
grep -rln "ai-ahu-chatbot" /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/ /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/*/scripts/ | head
Replace occurrences with ahu-ai-chatbot. Commit.
End-of-Plan-C success criteria
Run these once Task I2 is committed:
# From anywhere with LAN access:
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health
curl -N -H 'Content-Type: application/json' \
-d '{"surface":"public","history":[],"message":"Apa itu perseroan terbatas?","sessionId":"final-1"}' \
https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate --max-time 60 | grep -c '"delta"'
# Expected: real Indonesian answer with references; delta count > 10
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm typecheck
pnpm test
uv run pytest tests/isolation -v
ls /home/efran/remote-development/poc-ahu-ai/ | grep -E "^(ai-ahu|ahu-ai)-chatbot$"
# Expected: only ahu-ai-chatbot listed
Plan C is done when:
- ✓ Live prod returns real Indonesian legal answers (RAG loaded)
- ✓ /admin/{public,internal,shared,observe}/ routing works; ScopePill visible on each page
- ✓ Audit log records at least one mutation from Task B4
- ✓ SSE resume: interrupt + reconnect with Last-Event-ID resumes stream
- ✓ LLM calls carry X-Tenant-Id / X-Surface / X-User-Id / X-Priority headers
- ✓ At least one BullMQ job runs successfully via /admin/shared/jobs
- ✓
pnpm vitest run tests/orchestrator/sql-guard.test.ts— 7/7 pass - ✓ Native-provider handles both single-model and dual-model with test coverage
- ✓
docker exec ahu-ai-agent-public python -m dash.evals.run_model_comparison --limit 2runs without env skips - ✓
ls poc-ahu-ai/shows onlyahu-ai-chatbot(physical rename complete)