Monorepo Public/Internal Split — Plan A: Skeleton + Code Split
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: Land the monorepo skeleton, carve the current single Next.js app into separate public-web + internal-web apps, translate the Python orchestrator into TypeScript that lives inside public-web, and duplicate the Dash agent into isolated public-agent + internal-agent services with separate knowledge mounts.
Architecture: Single repo ahu-ai-chatbot/ with pnpm workspaces (TS) + uv workspaces (Python). Four deployable apps (public-web, internal-web, public-agent, internal-agent). Shared code in packages/{ui,streams,orchestrator-types,queue,agno-base,eval}. Knowledge isolated under infra/knowledge/{public,internal}/. No code path from public artifacts can reach internal data; internal admin code never compiles into the public bundle.
Tech Stack: Next.js 15 + React 19 + Tailwind 4 + TypeScript (apps/-web), FastAPI + Agno + Python 3.12 (apps/-agent), pnpm workspaces, uv workspaces, Docker Compose v2.
Companion plans:
- Plan B (2026-06-30-monorepo-split-plan-b-deploy-and-cutover.md, to be written) — Phase 2+3: build pipelines, compose stacks, staging deploys, DNS cutover.
- Plan C (2026-06-30-monorepo-split-plan-c-polish.md, to be written) — Phase 4 items: admin scope-switcher, audit log, SSE resume primitive, standard gateway request headers.
Spec: docs/superpowers/specs/2026-06-30-monorepo-public-internal-split-design.md
Where this plan runs
All 37 tasks operate on Server 1 (192.168.62.155, the 0-GPU server) inside /home/efran/remote-development/poc-ahu-ai/. This is the source-of-truth host where the codebase already lives. Plan A produces source code, runs unit tests, and runs smoke evals — none of which require local GPU. Smoke evals call out to vLLM on Server 2 over the LAN (http://192.168.83.20:8000).
Server 2 (GPU, 192.168.83.20) enters in Plan B, not Plan A. Plan B builds Docker images on Server 1, copies them to Server 2, deploys compose.public.yaml + compose.internal.yaml there, and flips DNS.
Environment variables for local dev on Server 1:
- MODEL_GATEWAY_URL=http://192.168.83.20:8000 (planning, local vLLM on Server 2)
- SYNTHESIS_GATEWAY_URL=<Alibaba Cloud Model Studio URL> (synthesis, transitional)
- LLM_PLANNING_MODEL=Qwen/Qwen3.6-35B-A3B-FP8
- LLM_SYNTHESIS_MODEL=qwen3.5-397b-a17b (Alibaba model id)
- RAG_BASE_URL=http://192.168.83.20:8110
- DATA_PUBLIC_BASE_URL=http://localhost:8000 (your public-agent running locally)
- DATA_STAFF_BASE_URL=http://localhost:8001 (your internal-agent running locally on a second port)
When the agents are run inside their Docker images on Server 2 (Plan B), these flip to Docker-DNS names (http://ahu-vllm:8000, http://public-agent:8000, etc.).
File Structure (end state of Plan A)
ahu-ai-chatbot/ # repo root (renamed from ai-ahu-chatbot)
├── package.json # workspace root
├── pnpm-workspace.yaml
├── pyproject.toml # uv workspace root
├── tsconfig.base.json
├── .editorconfig
├── Makefile # dev tasks
├── apps/
│ ├── public-web/ # Next.js — Tanya + orchestrator API routes
│ │ ├── package.json
│ │ ├── next.config.ts
│ │ ├── tsconfig.json
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── tanya/page.tsx
│ │ │ │ ├── beranda/page.tsx
│ │ │ │ └── api/
│ │ │ │ ├── orchestrate/route.ts
│ │ │ │ ├── health/route.ts
│ │ │ │ └── anon/{session,consume}/route.ts
│ │ │ ├── lib/
│ │ │ │ └── orchestrator/ # translated from ahu-chatbot-orchestrator
│ │ │ │ ├── orchestrate.ts
│ │ │ │ ├── sse.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── llm/client.ts
│ │ │ │ ├── steps/{plan,execute,evaluate,compose}.ts
│ │ │ │ ├── tools/{rag,data,base}.ts
│ │ │ │ ├── policy/{store,schema,defaults,api}.ts
│ │ │ │ ├── guards/{input_patterns,categories}.ts
│ │ │ │ └── sql_guard/{validator,result_filter}.ts
│ │ │ └── components/ # public-only UI
│ │ └── tests/
│ ├── internal-web/ # Next.js — Tanya Data + Admin
│ │ ├── package.json
│ │ ├── next.config.ts
│ │ ├── tsconfig.json
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── app/data/page.tsx
│ │ │ │ ├── admin/
│ │ │ │ │ ├── public/{knowledge,persona,policy,rag}/page.tsx
│ │ │ │ │ ├── internal/{knowledge,persona,schema}/page.tsx
│ │ │ │ │ ├── shared/{model-gateway,provider,jobs}/page.tsx
│ │ │ │ │ └── observe/{threads,evals,audit}/page.tsx
│ │ │ │ └── api/ # data + admin endpoints
│ │ │ └── components/ # internal-only UI
│ │ └── tests/
│ ├── public-agent/ # Python — restricted Dash variant
│ │ ├── pyproject.toml
│ │ ├── Dockerfile
│ │ ├── app/{main.py,__init__.py}
│ │ └── tests/
│ └── internal-agent/ # Python — full Dash variant
│ ├── pyproject.toml
│ ├── Dockerfile
│ ├── app/{main.py,__init__.py}
│ └── tests/
├── packages/
│ ├── ui/ # shared React components
│ │ ├── package.json
│ │ └── src/{chat,table,chart,admin,index.ts}
│ ├── streams/ # SSE + native-provider TS
│ │ ├── package.json
│ │ └── src/{native-provider,event-types,index.ts}
│ ├── orchestrator-types/ # Plan/Tool/Event types shared FE ↔ API routes
│ │ ├── package.json
│ │ └── src/{plan,events,tools,index.ts}
│ ├── queue/ # BullMQ workers (stub in Plan A; expanded in Plan C)
│ │ ├── package.json
│ │ └── src/index.ts
│ ├── agno-base/ # shared Python: tools, prompt scaffolding
│ │ ├── pyproject.toml
│ │ └── agno_base/
│ │ ├── __init__.py
│ │ ├── prompts/{public.py,internal.py,base.py}
│ │ ├── tools/{introspect,query,knowledge}.py
│ │ └── client/vllm.py
│ └── eval/ # eval harness (stub in Plan A)
│ └── pyproject.toml
├── infra/
│ ├── compose.dev.yaml # local dev (replaces current compose.dev.yaml)
│ └── knowledge/
│ ├── public/{tables,business,queries}/
│ └── internal/{tables,business,queries}/
├── docs/ # already exists
├── tests/
│ ├── e2e/
│ └── isolation/
└── tmp/ # scratch — deleted at end of Phase 1
└── orchestrator/ # git subtree from ahu-chatbot-orchestrator
Phase 0 — Repo skeleton
Task 1: Rename repo + capture safety branch
Files:
- Modify: top-level repo directory (/home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot → /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot)
- Modify: .git/config remote URL if any
- [ ] Step 1: Capture a safety branch with the current state
cd /home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot
git checkout -b pre-monorepo-snapshot
git push -u origin pre-monorepo-snapshot # only if remote is set
git checkout main
Expected: branch pre-monorepo-snapshot exists and points at current main.
- [ ] Step 2: Rename the working directory
cd /home/efran/remote-development/poc-ahu-ai
mv ai-ahu-chatbot ahu-ai-chatbot
cd ahu-ai-chatbot
Expected: pwd prints /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot.
- [ ] Step 3: Verify git still works
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot status
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot log --oneline -3
Expected: status clean (or pre-existing untracked files only); log shows recent commits.
- [ ] Step 4: Commit the rename rationale
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot commit --allow-empty -m "chore: working dir renamed ai-ahu-chatbot → ahu-ai-chatbot (no code change)"
Task 2: Set up pnpm workspace root
Files:
- Create: package.json (overwrite existing root package.json — current one becomes apps/internal-web/package.json in Task 7)
- Create: pnpm-workspace.yaml
- Create: tsconfig.base.json
- Modify: .gitignore
- [ ] Step 1: Move the existing root package.json out of the way
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p apps/internal-web
git mv package.json apps/internal-web/package.json
git mv pnpm-lock.yaml apps/internal-web/pnpm-lock.yaml 2>/dev/null || git mv package-lock.json apps/internal-web/package-lock.json 2>/dev/null || true
Expected: apps/internal-web/package.json exists with old contents; root has no package.json.
- [ ] Step 2: Write new workspace root
package.json
{
"name": "ahu-ai-chatbot",
"private": true,
"version": "0.0.0",
"scripts": {
"dev:public": "pnpm --filter @ahu/public-web dev",
"dev:internal": "pnpm --filter @ahu/internal-web dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck"
},
"packageManager": "pnpm@9.12.0",
"engines": { "node": ">=20" }
}
- [ ] Step 3: Write
pnpm-workspace.yaml
packages:
- 'apps/public-web'
- 'apps/internal-web'
- 'packages/ui'
- 'packages/streams'
- 'packages/orchestrator-types'
- 'packages/queue'
- [ ] Step 4: Write
tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"jsx": "preserve",
"incremental": true,
"resolveJsonModule": true
}
}
- [ ] Step 5: Append to
.gitignore
# Monorepo additions
node_modules/
**/.next/
**/.turbo/
**/dist/
tmp/
.venv/
.uv-cache/
**/*.tsbuildinfo
- [ ] Step 6: Install + verify
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
corepack enable
pnpm install
Expected: pnpm install succeeds; workspace shows 1 project (internal-web).
- [ ] Step 7: Commit
git add package.json pnpm-workspace.yaml tsconfig.base.json .gitignore apps/internal-web/
git commit -m "feat(monorepo): pnpm workspace root + relocate package.json under apps/internal-web"
Task 3: Set up uv (Python) workspace root
Files:
- Create: pyproject.toml (workspace root)
- Create: .python-version
- [ ] Step 1: Write workspace
pyproject.toml
[project]
name = "ahu-ai-chatbot"
version = "0.0.0"
description = "AHU AI Chatbot monorepo root"
requires-python = ">=3.12"
[tool.uv.workspace]
members = [
"apps/public-agent",
"apps/internal-agent",
"packages/agno-base",
"packages/eval",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
testpaths = ["apps/*/tests", "packages/*/tests"]
asyncio_mode = "auto"
- [ ] Step 2: Pin Python version
echo "3.12" > /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/.python-version
- [ ] Step 3: Sanity check uv is installed
uv --version
Expected: prints uv version 0.4+. If missing: pipx install uv or curl -LsSf https://astral.sh/uv/install.sh | sh.
- [ ] Step 4: Commit
git add pyproject.toml .python-version
git commit -m "feat(monorepo): uv workspace root with Python 3.12 pin"
Task 4: Subtree-merge ai-ahu-data-dash → apps/internal-agent
Files:
- Create: apps/internal-agent/ (entire tree, via subtree merge)
- [ ] Step 1: Add the sibling repo as a temporary remote
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git remote add -f dash-src /home/efran/remote-development/poc-ahu-ai/ai-ahu-data-dash
Expected: git remote -v lists dash-src.
- [ ] Step 2: Subtree-merge into
apps/internal-agent
git subtree add --prefix=apps/internal-agent dash-src main
Expected: a merge commit on main; apps/internal-agent/ now contains the dash tree (app/, dash/, compose.yaml, Dockerfile, etc.).
- [ ] Step 3: Verify
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/
test -f /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/Dockerfile && echo OK
test -f /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/dash/agents.py && echo OK
Expected: prints OK twice.
- [ ] Step 4: Disconnect the temporary remote
git remote remove dash-src
Task 5: Subtree-merge ahu-chatbot-orchestrator → tmp/orchestrator
Files:
- Create: tmp/orchestrator/ (entire tree)
- [ ] Step 1: Add the sibling repo as a temporary remote
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot remote add -f orch-src /home/efran/remote-development/poc-ahu-ai/ahu-chatbot-orchestrator
- [ ] Step 2: Subtree-merge into
tmp/orchestrator
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot subtree add --prefix=tmp/orchestrator orch-src main
Expected: tmp/orchestrator/src/orchestrator/ exists with the Python code.
- [ ] Step 3: Verify
test -f /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/tmp/orchestrator/src/orchestrator/orchestrate.py && echo OK
- [ ] Step 4: Disconnect
git -C /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot remote remove orch-src
Task 6: Top-level directory scaffolding
Files:
- Create: apps/public-web/.gitkeep
- Create: apps/public-agent/.gitkeep
- Create: packages/{ui,streams,orchestrator-types,queue,agno-base,eval}/.gitkeep
- Create: infra/knowledge/public/{tables,business,queries}/.gitkeep
- Create: infra/knowledge/internal/{tables,business,queries}/.gitkeep
- Create: tests/{e2e,isolation}/.gitkeep
- Create: Makefile
- [ ] Step 1: Create empty directories tracked via .gitkeep
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
for d in apps/public-web apps/public-agent \
packages/ui packages/streams packages/orchestrator-types packages/queue packages/agno-base packages/eval \
infra/knowledge/public/tables infra/knowledge/public/business infra/knowledge/public/queries \
infra/knowledge/internal/tables infra/knowledge/internal/business infra/knowledge/internal/queries \
tests/e2e tests/isolation; do
mkdir -p "$d"
touch "$d/.gitkeep"
done
- [ ] Step 2: Write top-level
Makefile
.PHONY: dev-public dev-internal build test typecheck lint install
install:
pnpm install
uv sync --all-packages
dev-public:
pnpm dev:public
dev-internal:
pnpm dev:internal
build:
pnpm build
typecheck:
pnpm typecheck
lint:
pnpm lint
test:
pnpm test
uv run pytest
isolation-test:
pnpm --filter @ahu/public-web build
uv run pytest tests/isolation -v
- [ ] Step 3: Commit
git add apps packages infra tests Makefile
git commit -m "feat(monorepo): top-level directory scaffolding for apps/packages/infra/tests"
Task 7: Rename internal-web package + verify it still builds
Files:
- Modify: apps/internal-web/package.json — change name field
- [ ] Step 1: Read current
apps/internal-web/package.json
Look for the "name" field at the top. Confirmed value at plan-write time: "ahu-chatbot".
- [ ] Step 2: Change name + add workspace-aware scripts
Replace the "name" value with "@ahu/internal-web". Leave all other fields untouched.
{
"name": "@ahu/internal-web",
...
}
- [ ] Step 3: Reinstall + build
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm install
pnpm --filter @ahu/internal-web build
Expected: build succeeds (Next.js output: .next directory created under apps/internal-web/).
- [ ] Step 4: Commit
git add apps/internal-web/package.json pnpm-lock.yaml
git commit -m "chore(internal-web): rename package to @ahu/internal-web and verify workspace build"
Phase 1A — Carve out apps/public-web
Task 8: Scaffold apps/public-web Next.js skeleton (copying from internal-web)
Files:
- Create: apps/public-web/package.json
- Create: apps/public-web/next.config.ts
- Create: apps/public-web/tsconfig.json
- Create: apps/public-web/postcss.config.mjs
- Create: apps/public-web/tailwind.config.ts
- Create: apps/public-web/src/app/layout.tsx
- Create: apps/public-web/src/app/globals.css (copied from internal-web)
- Create: apps/public-web/src/app/page.tsx
- [ ] Step 1: Copy non-route framework files from internal-web
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
cp apps/internal-web/next.config.ts apps/public-web/next.config.ts
cp apps/internal-web/postcss.config.mjs apps/public-web/postcss.config.mjs
cp apps/internal-web/tailwind.config.ts apps/public-web/tailwind.config.ts
cp apps/internal-web/tsconfig.json apps/public-web/tsconfig.json
mkdir -p apps/public-web/src/app
cp apps/internal-web/src/app/globals.css apps/public-web/src/app/globals.css
- [ ] Step 2: Write
apps/public-web/package.json
Look at apps/internal-web/package.json for current Next.js + React versions. Mirror them.
{
"name": "@ahu/public-web",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "next dev -p 3501",
"build": "next build",
"start": "next start -p 3501",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "<COPY FROM internal-web/package.json>",
"react": "<COPY>",
"react-dom": "<COPY>"
},
"devDependencies": {
"typescript": "<COPY>",
"@types/node": "<COPY>",
"@types/react": "<COPY>",
"@types/react-dom": "<COPY>",
"tailwindcss": "<COPY>",
"@tailwindcss/postcss": "<COPY>",
"eslint": "<COPY>",
"eslint-config-next": "<COPY>"
}
}
Replace each <COPY FROM ...> with the exact version string from apps/internal-web/package.json.
- [ ] Step 3: Write a minimal
apps/public-web/src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AHU Tanya",
description: "Layanan tanya jawab publik Direktorat Jenderal AHU",
};
export default function RootLayout({
children,
}: { children: React.ReactNode }) {
return (
<html lang="id">
<body>{children}</body>
</html>
);
}
- [ ] Step 4: Write a stub
apps/public-web/src/app/page.tsxthat redirects to/tanya
import { redirect } from "next/navigation";
export default function Home() {
redirect("/tanya");
}
- [ ] Step 5: Install + verify the empty public-web builds
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm install
pnpm --filter @ahu/public-web build
Expected: build succeeds; produces apps/public-web/.next/.
- [ ] Step 6: Commit
git add apps/public-web pnpm-lock.yaml
git commit -m "feat(public-web): scaffold Next.js app skeleton (mirrors internal-web framework config)"
Task 8b: Add vitest to both web apps
The existing chatbot uses bespoke tsx scripts/test-*.ts files (see apps/internal-web/scripts/). Those keep working untouched. NEW tests written under tests/ use vitest for ergonomic mocking + parallel execution.
Files:
- Modify: apps/public-web/package.json (add vitest)
- Modify: apps/internal-web/package.json (add vitest)
- Create: apps/public-web/vitest.config.ts
- Create: apps/internal-web/vitest.config.ts
- [ ] Step 1: Add vitest to both apps
In both apps/public-web/package.json and apps/internal-web/package.json:
- Add to devDependencies: "vitest": "^2.1.4", "@vitest/coverage-v8": "^2.1.4"
- Add to scripts: "test": "vitest run", "test:watch": "vitest"
- [ ] Step 2: Write
apps/public-web/vitest.config.ts
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
});
-
[ ] Step 3: Write
apps/internal-web/vitest.config.ts— same content as Step 2 but in the internal-web dir. -
[ ] Step 4: Install + smoke
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm install
pnpm --filter @ahu/public-web exec vitest --version
Expected: prints 2.x.x.
- [ ] Step 5: Commit
git add apps/public-web apps/internal-web pnpm-lock.yaml
git commit -m "chore: add vitest to both web apps (existing tsx test scripts unchanged)"
Task 9: Move public routes (/tanya, /beranda) from internal-web to public-web
Files:
- Move: apps/internal-web/src/app/(public)/tanya/ → apps/public-web/src/app/tanya/
- Move: apps/internal-web/src/app/(public)/beranda/ → apps/public-web/src/app/beranda/
- Delete: apps/internal-web/src/app/(public)/ (empty after moves)
- [ ] Step 1: Verify the routes exist where expected
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/app/\(public\)/
Expected: tanya and beranda directories.
- [ ] Step 2: Move both route groups
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git mv "apps/internal-web/src/app/(public)/tanya" apps/public-web/src/app/tanya
git mv "apps/internal-web/src/app/(public)/beranda" apps/public-web/src/app/beranda
rmdir "apps/internal-web/src/app/(public)"
- [ ] Step 3: Audit imports inside the moved files
grep -rn "@/" apps/public-web/src/app/tanya apps/public-web/src/app/beranda
Expected: a list of @/components/..., @/lib/..., @/store/..., @/hooks/... import paths. These need to be redirected in subsequent tasks once we know which target lives in public-web, which in packages/ui, etc.
- [ ] Step 4: Don't try to build yet — public-web imports are broken until Task 12 (move shared deps) lands. Just commit the move.
git add apps/public-web apps/internal-web
git commit -m "refactor: move /tanya and /beranda routes from internal-web to public-web (imports temporarily broken)"
Task 10: Move public-facing API routes to public-web
Files:
- Move: apps/internal-web/src/app/api/chat/public/ → apps/public-web/src/app/api/chat/public/
- Move: apps/internal-web/src/app/api/anon/ → apps/public-web/src/app/api/anon/
- Move: apps/internal-web/src/app/api/threads/doc/ → apps/public-web/src/app/api/threads/doc/
- [ ] Step 1: Move them
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p apps/public-web/src/app/api/chat apps/public-web/src/app/api/threads
git mv apps/internal-web/src/app/api/chat/public apps/public-web/src/app/api/chat/public
git mv apps/internal-web/src/app/api/anon apps/public-web/src/app/api/anon
git mv apps/internal-web/src/app/api/threads/doc apps/public-web/src/app/api/threads/doc
- [ ] Step 2: Add a public health endpoint
Create apps/public-web/src/app/api/health/route.ts:
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ok: true,
surface: "public",
timestamp: new Date().toISOString(),
});
}
- [ ] Step 3: Commit
git add apps/public-web apps/internal-web
git commit -m "refactor: relocate public-facing API routes (chat/public, anon, threads/doc) + add /api/health"
Task 11: Trim internal-web to internal-only surface
Internal-web still has (staff)/, (protected)/, login/, chat/, dev/, and a long list of admin routes — those stay. But there may be leftover frontend bits that were only for public.
Files:
- Inspect: apps/internal-web/src/app/
- Possibly delete: apps/internal-web/src/app/chat/ if it's the public Tanya entry route (move it to public-web instead). Check first.
- [ ] Step 1: List remaining routes
find /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/app -maxdepth 2 -type d | sort
- [ ] Step 2: Identify any public-only artifacts
Look at:
- apps/internal-web/src/app/chat/ — likely the legacy public chat page. Open page.tsx and check if it's a public route or staff-only.
- apps/internal-web/src/app/dev/ — dev tooling, keep on internal.
- [ ] Step 3: Move public-leaning leftovers to public-web (if found in Step 2)
If apps/internal-web/src/app/chat/page.tsx looks like a duplicate Tanya entry, move it:
git mv apps/internal-web/src/app/chat apps/public-web/src/app/chat
If it's staff-only, leave it.
- [ ] Step 4: Commit (skip if no moves needed)
git add apps/public-web apps/internal-web
git commit -m "refactor: relocate stray public-leaning routes out of internal-web (if any)"
Phase 1B — Extract shared packages
Task 12: Extract packages/streams (native-provider + SSE types)
Files:
- Create: packages/streams/package.json
- Create: packages/streams/tsconfig.json
- Create: packages/streams/src/index.ts
- Move: apps/internal-web/src/lib/streams/native-provider.ts → packages/streams/src/native-provider.ts
- Move: every other file currently under apps/internal-web/src/lib/streams/ → packages/streams/src/
- [ ] Step 1: Inventory streams files
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/lib/streams/
- [ ] Step 2: Move them
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p packages/streams/src
git mv apps/internal-web/src/lib/streams/* packages/streams/src/
rmdir apps/internal-web/src/lib/streams
- [ ] Step 3: Write
packages/streams/package.json
{
"name": "@ahu/streams",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"rxjs": "^7.8.1"
},
"devDependencies": {
"typescript": "<COPY FROM root>"
}
}
- [ ] Step 4: Write
packages/streams/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"noEmit": false
},
"include": ["src"]
}
- [ ] Step 5: Write
packages/streams/src/index.ts(barrel)
Look at what's in packages/streams/src/ after Step 2. List the file basenames (e.g. native-provider.ts, event-types.ts, etc.). Write:
export * from "./native-provider";
// add each file as another export line — one per file currently in src/
- [ ] Step 6: Make both apps depend on it
In apps/internal-web/package.json dependencies, add: "@ahu/streams": "workspace:*".
In apps/public-web/package.json dependencies, add: "@ahu/streams": "workspace:*".
- [ ] Step 7: Update imports inside both apps
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
grep -rln "from \"@/lib/streams" apps/ | xargs sed -i 's|@/lib/streams|@ahu/streams|g'
grep -rln "from '@/lib/streams" apps/ | xargs sed -i "s|@/lib/streams|@ahu/streams|g"
- [ ] Step 8: Install + typecheck
pnpm install
pnpm --filter @ahu/internal-web typecheck
pnpm --filter @ahu/streams typecheck
Expected: no @/lib/streams import errors. Other unrelated import errors are OK (we fix them in subsequent tasks).
- [ ] Step 9: Commit
git add packages/streams apps pnpm-lock.yaml
git commit -m "refactor: extract @ahu/streams package from internal-web/src/lib/streams"
Task 13: Extract packages/orchestrator-types
Files:
- Create: packages/orchestrator-types/package.json
- Create: packages/orchestrator-types/tsconfig.json
- Create: packages/orchestrator-types/src/{plan,events,tools,index}.ts
These types are the contract between the future TS orchestrator (Task 18+) and the FE that consumes its SSE stream. Defining them now lets both ends import the same package.
- [ ] Step 1: Write
packages/orchestrator-types/package.json
{
"name": "@ahu/orchestrator-types",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc --noEmit" },
"devDependencies": { "typescript": "<COPY>" }
}
-
[ ] Step 2: Write
packages/orchestrator-types/tsconfig.json— same shape aspackages/streams/tsconfig.json. -
[ ] Step 3: Write
packages/orchestrator-types/src/plan.ts
(Mirrors tmp/orchestrator/src/orchestrator/steps/plan.py — open that file to confirm field names.)
export interface ToolCall {
tool: string;
prompt: string;
}
export interface Plan {
reasoning: string;
calls: ToolCall[];
}
export interface Refusal {
kind: "refusal";
reason: string;
}
export type PlanOrRefusal = Plan | Refusal;
export function isRefusal(x: PlanOrRefusal): x is Refusal {
return (x as Refusal).kind === "refusal";
}
- [ ] Step 4: Write
packages/orchestrator-types/src/events.ts
(Mirrors tmp/orchestrator/src/orchestrator/sse.py.)
export type SseEvent =
| { event: "status"; data: { text: string } }
| { event: "content"; data: { delta: string } }
| { event: "references"; data: { items: Reference[] } }
| { event: "done"; data: Record<string, never> }
| { event: "error"; data: { message: string } };
export interface Reference {
title: string;
source: string;
url?: string;
snippet?: string;
}
- [ ] Step 5: Write
packages/orchestrator-types/src/tools.ts
(Mirrors tmp/orchestrator/src/orchestrator/tools/base.py.)
export interface ToolResult {
tool: string;
prompt: string;
ok: boolean;
response?: string;
references?: import("./events").Reference[];
error?: string;
duration_ms: number;
}
- [ ] Step 6: Write
packages/orchestrator-types/src/index.ts
export * from "./plan";
export * from "./events";
export * from "./tools";
- [ ] Step 7: Install + typecheck
pnpm install
pnpm --filter @ahu/orchestrator-types typecheck
- [ ] Step 8: Commit
git add packages/orchestrator-types pnpm-lock.yaml
git commit -m "feat(orchestrator-types): publish Plan/Tool/Event types for FE↔orchestrator contract"
Task 14: Extract packages/ui (shared React components)
Files:
- Create: packages/ui/package.json, tsconfig.json, src/index.ts
- Move (selective): components that are used by BOTH public-web and internal-web.
The judgement call: a component goes into packages/ui if it's used in both apps OR is non-trivial generic chrome (chat message bubbles, tables, charts, dialogs). App-specific components stay in their app.
- [ ] Step 1: Inventory shared components by grepping for cross-app references
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
ls apps/internal-web/src/components/
# Typically: admin/, beranda/, chat/, icons/, knowledge/, layout/, shell/, ui/
- [ ] Step 2: Move generic ones used by
tanya/berandaANDapp/data/admintopackages/ui/src/
Candidates to move (verify each by grepping where it's used):
- apps/internal-web/src/components/ui/ → packages/ui/src/primitives/
- apps/internal-web/src/components/chat/ (sub-files that are surface-agnostic) → packages/ui/src/chat/
- apps/internal-web/src/components/icons/ → packages/ui/src/icons/
For each candidate dir, run:
grep -rln "from \"@/components/<NAME>" apps/public-web && echo USED-BY-PUBLIC
grep -rln "from \"@/components/<NAME>" apps/internal-web && echo USED-BY-INTERNAL
Move only if BOTH USED-BY-PUBLIC and USED-BY-INTERNAL print.
- [ ] Step 3: Move them
For each confirmed shared dir <NAME>:
mkdir -p packages/ui/src
git mv apps/internal-web/src/components/<NAME> packages/ui/src/<NAME>
- [ ] Step 4: Write
packages/ui/package.json
{
"name": "@ahu/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": {
"react": "<COPY>",
"react-dom": "<COPY>",
"clsx": "<COPY IF USED, else omit>",
"lucide-react": "<COPY>"
},
"peerDependencies": { "react": "^19", "react-dom": "^19" },
"devDependencies": {
"typescript": "<COPY>",
"@types/react": "<COPY>"
}
}
- [ ] Step 5: Write
packages/ui/tsconfig.json— shape mirrorspackages/streams/tsconfig.jsonbut adds"jsx": "react-jsx"override.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"jsx": "react-jsx",
"noEmit": false
},
"include": ["src"]
}
- [ ] Step 6: Write
packages/ui/src/index.ts— re-export each moved dir's barrel
For each dir, add a line. Example if you moved primitives, chat, icons:
export * from "./primitives";
export * from "./chat";
export * from "./icons";
If a subdir lacks its own index.ts, write one that re-exports the components.
- [ ] Step 7: Add the dep to both apps
In both apps/internal-web/package.json and apps/public-web/package.json dependencies: "@ahu/ui": "workspace:*".
- [ ] Step 8: Update imports
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
# For each moved dir <NAME>:
grep -rln "from \"@/components/<NAME>" apps/ | xargs sed -i 's|@/components/<NAME>|@ahu/ui/<NAME>|g'
Repeat with single-quote variant. Repeat per moved directory.
- [ ] Step 9: Install + typecheck
pnpm install
pnpm --filter @ahu/ui typecheck
pnpm --filter @ahu/internal-web typecheck
Expected: no @/components/<MOVED> errors. Other errors OK for now.
- [ ] Step 10: Commit
git add packages/ui apps pnpm-lock.yaml
git commit -m "refactor: extract @ahu/ui package for components used by both web apps"
Task 15: Add packages/queue stub
Files:
- Create: packages/queue/package.json, tsconfig.json, src/index.ts
- [ ] Step 1: Write
packages/queue/package.json
{
"name": "@ahu/queue",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": { "bullmq": "^5.18.0", "ioredis": "^5.4.1" },
"devDependencies": { "typescript": "<COPY>" }
}
-
[ ] Step 2: Write
packages/queue/tsconfig.json— mirrorpackages/streams/tsconfig.json. -
[ ] Step 3: Write
packages/queue/src/index.ts
import { Queue, Worker, type QueueOptions, type WorkerOptions } from "bullmq";
import IORedis from "ioredis";
export function makeRedis(url: string = process.env.REDIS_URL ?? "redis://localhost:6379") {
return new IORedis(url, { maxRetriesPerRequest: null });
}
export function makeQueue<T>(name: string, opts: Partial<QueueOptions> = {}) {
return new Queue<T>(name, { connection: makeRedis(), ...opts });
}
export function makeWorker<T>(
name: string,
handler: (job: { data: T }) => Promise<unknown>,
opts: Partial<WorkerOptions> = {},
) {
return new Worker<T>(name, handler, { connection: makeRedis(), ...opts });
}
export { Queue, Worker };
- [ ] Step 4: Install + typecheck
pnpm install
pnpm --filter @ahu/queue typecheck
- [ ] Step 5: Commit
git add packages/queue pnpm-lock.yaml
git commit -m "feat(queue): scaffold @ahu/queue package (BullMQ + ioredis) for background jobs"
Phase 1C — Translate orchestrator to TypeScript
The Python orchestrator lives in tmp/orchestrator/src/orchestrator/. Translation target is apps/public-web/src/lib/orchestrator/. Each task translates one Python module to one TypeScript module. Test coverage matches each step.
Task 16: Translate sse.py → lib/orchestrator/sse.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/sse.py (30 lines)
- Create: apps/public-web/src/lib/orchestrator/sse.ts
- Create: apps/public-web/tests/orchestrator/sse.test.ts
- [ ] Step 1: Write the test first
import { describe, it, expect } from "vitest";
import * as sse from "../../src/lib/orchestrator/sse";
describe("sse", () => {
it("encodes status events", () => {
expect(sse.status("hi")).toBe('event: status\ndata: {"text":"hi"}\n\n');
});
it("encodes content delta", () => {
expect(sse.content("x")).toBe('event: content\ndata: {"delta":"x"}\n\n');
});
it("encodes references", () => {
expect(sse.references([{ title: "t", source: "s" }])).toBe(
'event: references\ndata: {"items":[{"title":"t","source":"s"}]}\n\n',
);
});
it("encodes done as empty object", () => {
expect(sse.done()).toBe("event: done\ndata: {}\n\n");
});
it("encodes error", () => {
expect(sse.error("boom")).toBe('event: error\ndata: {"message":"boom"}\n\n');
});
});
- [ ] Step 2: Run test — expect failure (file doesn't exist)
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-web
pnpm vitest run tests/orchestrator/sse.test.ts
Expected: FAIL with module-not-found.
- [ ] Step 3: Write the implementation
// apps/public-web/src/lib/orchestrator/sse.ts
import type { Reference } from "@ahu/orchestrator-types";
function event(name: string, data: Record<string, unknown> = {}): string {
const payload = JSON.stringify(data);
return `event: ${name}\ndata: ${payload}\n\n`;
}
export const status = (text: string) => event("status", { text });
export const content = (delta: string) => event("content", { delta });
export const references = (items: Reference[]) => event("references", { items });
export const done = () => event("done", {});
export const error = (message: string) => event("error", { message });
- [ ] Step 4: Run tests — expect pass
pnpm vitest run tests/orchestrator/sse.test.ts
Expected: 5/5 pass.
- [ ] Step 5: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add apps/public-web/src/lib/orchestrator/sse.ts apps/public-web/tests/orchestrator/sse.test.ts
git commit -m "feat(orchestrator/sse): port SSE event encoders to TS"
Task 17: Translate config.py → lib/orchestrator/config.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/config.py (88 lines, SQLite-backed Config + ConfigStore)
- Create: apps/public-web/src/lib/orchestrator/config.ts
- Create: apps/public-web/tests/orchestrator/config.test.ts
- Add dep: better-sqlite3 in apps/public-web/package.json
- [ ] Step 1: Add the SQLite client
In apps/public-web/package.json dependencies, add "better-sqlite3": "^11.3.0" and "@types/better-sqlite3": "^7.6.11" to devDependencies. Run pnpm install.
- [ ] Step 2: Write the test
import { describe, it, expect } from "vitest";
import { tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore, DEFAULTS } from "../../src/lib/orchestrator/config";
function tempPath() {
const d = mkdtempSync(join(tmpdir(), "cfg-"));
return join(d, "cfg.sqlite");
}
describe("ConfigStore", () => {
it("returns defaults on first load", () => {
const store = new ConfigStore(tempPath());
expect(store.load()).toEqual(DEFAULTS);
});
it("persists save/load", () => {
const p = tempPath();
const a = new ConfigStore(p);
a.save({ ...DEFAULTS, max_tool_call_rounds: 4 });
const b = new ConfigStore(p);
expect(b.load().max_tool_call_rounds).toBe(4);
});
it("validates ranges", () => {
const store = new ConfigStore(tempPath());
expect(() => store.save({ ...DEFAULTS, max_tool_call_rounds: 99 })).toThrow();
});
});
- [ ] Step 3: Run test — expect failure
pnpm vitest run tests/orchestrator/config.test.ts
Expected: FAIL with module-not-found.
- [ ] Step 4: Write the implementation
(Refer to tmp/orchestrator/src/orchestrator/config.py for DEFAULTS values + RANGES bounds.)
// apps/public-web/src/lib/orchestrator/config.ts
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface Config {
max_tool_call_rounds: number;
wall_clock_timeout_seconds: number;
per_tool_timeout_seconds: number;
enable_llm_judge: boolean;
judge_model: string;
rag_sufficiency_threshold: number;
rag_refusal_threshold: number;
enable_public_data: boolean;
enable_staff_data: boolean;
}
export const DEFAULTS: Config = {
max_tool_call_rounds: 2,
wall_clock_timeout_seconds: 20,
per_tool_timeout_seconds: 12,
enable_llm_judge: true,
judge_model: "vllm-small",
rag_sufficiency_threshold: 0.6,
rag_refusal_threshold: 0.35,
enable_public_data: true,
enable_staff_data: false,
};
const RANGES: Partial<Record<keyof Config, [number, number]>> = {
max_tool_call_rounds: [1, 5],
wall_clock_timeout_seconds: [5, 180],
per_tool_timeout_seconds: [5, 90],
rag_sufficiency_threshold: [0, 1],
rag_refusal_threshold: [0, 1],
};
function validate(cfg: Config) {
for (const [key, [lo, hi]] of Object.entries(RANGES) as [keyof Config, [number, number]][]) {
const v = cfg[key] as number;
if (v < lo || v > hi) throw new Error(`${key}=${v} out of range [${lo},${hi}]`);
}
}
export class ConfigStore {
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 config (id INTEGER PRIMARY KEY CHECK (id=1), payload TEXT NOT NULL)",
);
const row = this.db.prepare("SELECT payload FROM config WHERE id=1").get();
if (!row) {
this.db.prepare("INSERT INTO config (id, payload) VALUES (1, ?)").run(JSON.stringify(DEFAULTS));
}
}
load(): Config {
const row = this.db.prepare("SELECT payload FROM config WHERE id=1").get() as
| { payload: string }
| undefined;
if (!row) return { ...DEFAULTS };
return { ...DEFAULTS, ...JSON.parse(row.payload) };
}
save(cfg: Config): void {
validate(cfg);
this.db.prepare("UPDATE config SET payload = ? WHERE id=1").run(JSON.stringify(cfg));
}
}
- [ ] Step 5: Run tests — expect pass
pnpm vitest run tests/orchestrator/config.test.ts
Expected: 3/3 pass.
- [ ] Step 6: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add apps/public-web pnpm-lock.yaml
git commit -m "feat(orchestrator/config): port SQLite ConfigStore + validation to TS"
Task 18: Translate llm/client.py → lib/orchestrator/llm/client.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/llm/client.py (89 lines)
- Create: apps/public-web/src/lib/orchestrator/llm/client.ts
- Create: apps/public-web/tests/orchestrator/llm-client.test.ts
- [ ] Step 1: Write the test (mock fetch)
import { describe, it, expect, vi, beforeEach } from "vitest";
import { LlmClient } from "../../src/lib/orchestrator/llm/client";
describe("LlmClient", () => {
beforeEach(() => {
vi.spyOn(global, "fetch").mockReset();
});
it("complete returns content from OpenAI-shaped response", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: "hi" } }] }), { status: 200 }),
);
const cl = new LlmClient("http://x", "m");
expect(await cl.complete([{ role: "user", content: "?" }])).toBe("hi");
});
it("completeJson parses JSON object output", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"a":1}' } }] }), { status: 200 }),
);
const cl = new LlmClient("http://x", "m");
expect(await cl.completeJson([{ role: "user", content: "?" }])).toEqual({ a: 1 });
});
it("completeJson extracts first balanced JSON object from leading prose", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: 'noise {"a":1} tail' } }] }), { status: 200 }),
);
const cl = new LlmClient("http://x", "m");
expect(await cl.completeJson([{ role: "user", content: "?" }])).toEqual({ a: 1 });
});
});
- [ ] Step 2: Run test — expect failure
pnpm vitest run tests/orchestrator/llm-client.test.ts
- [ ] Step 3: Write the implementation
// apps/public-web/src/lib/orchestrator/llm/client.ts
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface ChatRequestExtras {
response_format?: { type: "json_object" };
chat_template_kwargs?: Record<string, unknown>;
temperature?: number;
max_tokens?: number;
// forward-compat headers consumed by future gateway
headers?: Record<string, string>;
}
export class LlmClient {
constructor(
private baseUrl: string,
private model: string,
private apiKey: string = "EMPTY",
private timeoutMs: number = 60_000,
) {
this.baseUrl = this.baseUrl.replace(/\/$/, "");
}
private async post(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
try {
const r = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
...extraHeaders,
},
body: JSON.stringify(body),
signal: ctl.signal,
});
if (!r.ok) throw new Error(`LLM HTTP ${r.status}: ${await r.text()}`);
return r;
} finally {
clearTimeout(t);
}
}
async complete(messages: ChatMessage[], extras: ChatRequestExtras = {}): Promise<string> {
const { headers, ...body } = extras;
const r = await this.post(
"/v1/chat/completions",
{ model: this.model, messages, stream: false, ...body },
headers,
);
const j = (await r.json()) as { choices: { message: { content: string } }[] };
return j.choices[0].message.content;
}
async completeJson<T = unknown>(messages: ChatMessage[], extras: ChatRequestExtras = {}): Promise<T> {
const merged: ChatRequestExtras = {
response_format: { type: "json_object" },
chat_template_kwargs: { enable_thinking: false },
...extras,
};
const text = await this.complete(messages, merged);
try {
return JSON.parse(text) as T;
} catch {
const start = text.indexOf("{");
if (start >= 0) {
let depth = 0;
for (let i = start; i < text.length; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}") {
depth--;
if (depth === 0) {
try {
return JSON.parse(text.slice(start, i + 1)) as T;
} catch {
break;
}
}
}
}
}
throw new Error(`malformed JSON from LLM: ${text.slice(0, 200)}`);
}
}
async *stream(messages: ChatMessage[], extras: ChatRequestExtras = {}): AsyncGenerator<string> {
const { headers, ...body } = {
chat_template_kwargs: { enable_thinking: false },
...extras,
};
const r = await this.post(
"/v1/chat/completions",
{ model: this.model, messages, stream: true, ...body },
headers,
);
if (!r.body) return;
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
try {
const obj = JSON.parse(data) as { choices: { delta?: { content?: string } }[] };
const delta = obj.choices?.[0]?.delta?.content;
if (delta) yield delta;
} catch {
/* ignore non-json keepalive */
}
}
}
}
}
- [ ] Step 4: Run tests — expect pass
pnpm vitest run tests/orchestrator/llm-client.test.ts
Expected: 3/3 pass.
- [ ] Step 5: Commit
git add apps/public-web/src/lib/orchestrator/llm apps/public-web/tests/orchestrator/llm-client.test.ts
git commit -m "feat(orchestrator/llm): port LlmClient (complete/completeJson/stream) to TS"
Task 19: Translate tools/{base,rag,data}.py → lib/orchestrator/tools/
Files:
- Reference: tmp/orchestrator/src/orchestrator/tools/base.py (18 lines), rag.py (37 lines), data.py (127 lines)
- Create: apps/public-web/src/lib/orchestrator/tools/base.ts
- Create: apps/public-web/src/lib/orchestrator/tools/rag.ts
- Create: apps/public-web/src/lib/orchestrator/tools/data.ts
- Create: apps/public-web/tests/orchestrator/tools.test.ts
- [ ] Step 1: Write test stubs that mock
fetchfor RAG + Data
import { describe, it, expect, vi } from "vitest";
import { RagTool } from "../../src/lib/orchestrator/tools/rag";
describe("RagTool", () => {
it("returns ok=true on 200 with normalized references", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ answer: "ok", references: [{ title: "t", source: "s" }] }),
{ status: 200 },
),
);
const tool = new RagTool("http://r", 10);
const out = await tool.call("hello");
expect(out.ok).toBe(true);
expect(out.tool).toBe("rag");
expect(out.references?.length).toBe(1);
});
it("returns ok=false on non-2xx", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(new Response("nope", { status: 500 }));
const tool = new RagTool("http://r", 10);
const out = await tool.call("x");
expect(out.ok).toBe(false);
expect(out.error).toMatch(/HTTP 500/);
});
});
- [ ] Step 2: Write
tools/base.ts
import type { ToolResult } from "@ahu/orchestrator-types";
export interface ToolFn {
(prompt: string): Promise<ToolResult>;
}
export function nowMs(): number {
return performance.now();
}
export { ToolResult };
- [ ] Step 3: Write
tools/rag.ts
import type { Reference, ToolResult } from "@ahu/orchestrator-types";
import { nowMs } from "./base";
export class RagTool {
constructor(private baseUrl: string, private timeoutSec: number) {
this.baseUrl = this.baseUrl.replace(/\/$/, "");
}
async call(prompt: string): Promise<ToolResult> {
const t0 = nowMs();
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), this.timeoutSec * 1000);
try {
const r = await fetch(`${this.baseUrl}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: prompt }),
signal: ctl.signal,
});
const dt = nowMs() - t0;
if (!r.ok) {
return { tool: "rag", prompt, ok: false, error: `HTTP ${r.status}`, duration_ms: dt };
}
const j = (await r.json()) as { answer?: string; references?: Reference[] };
return {
tool: "rag",
prompt,
ok: true,
response: j.answer ?? "",
references: j.references ?? [],
duration_ms: dt,
};
} catch (e) {
return {
tool: "rag",
prompt,
ok: false,
error: e instanceof Error ? e.message : String(e),
duration_ms: nowMs() - t0,
};
} finally {
clearTimeout(timer);
}
}
}
- [ ] Step 4: Write
tools/data.ts— Agno SSE consumer
(Refer to tmp/orchestrator/src/orchestrator/tools/data.py for the Agno SSE event-name set: RunResponseStarted, RunResponseContent, RunResponseCompleted, etc.)
import type { ToolResult } from "@ahu/orchestrator-types";
import { nowMs } from "./base";
export interface DataToolPolicy {
// forward-compat: policy hook surface
prompt_injection?: string;
}
export class DataTool {
constructor(
private baseUrl: string,
private agentId: string,
private name: string,
private timeoutSec: number,
) {
this.baseUrl = this.baseUrl.replace(/\/$/, "");
}
async call(prompt: string, opts: { policy?: DataToolPolicy } = {}): Promise<ToolResult> {
const t0 = nowMs();
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), this.timeoutSec * 1000);
try {
const finalPrompt = opts.policy?.prompt_injection
? `${opts.policy.prompt_injection}\n\n${prompt}`
: prompt;
const r = await fetch(`${this.baseUrl}/agents/${this.agentId}/runs`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify({ input: finalPrompt, stream: true }),
signal: ctl.signal,
});
if (!r.ok || !r.body) {
return {
tool: this.name,
prompt,
ok: false,
error: `HTTP ${r.status}`,
duration_ms: nowMs() - t0,
};
}
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let response = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data) continue;
try {
const obj = JSON.parse(data) as { event?: string; content?: string };
if (obj.event === "RunResponseContent" && typeof obj.content === "string") {
response += obj.content;
}
} catch {
/* ignore */
}
}
}
return { tool: this.name, prompt, ok: true, response, duration_ms: nowMs() - t0 };
} catch (e) {
return {
tool: this.name,
prompt,
ok: false,
error: e instanceof Error ? e.message : String(e),
duration_ms: nowMs() - t0,
};
} finally {
clearTimeout(timer);
}
}
}
- [ ] Step 5: Run tests
pnpm vitest run tests/orchestrator/tools.test.ts
Expected: 2/2 pass for RagTool. (Add DataTool tests next iteration if hot-spot rewrite #1 expands them; for now Rag coverage suffices for the contract.)
- [ ] Step 6: Commit
git add apps/public-web/src/lib/orchestrator/tools apps/public-web/tests/orchestrator/tools.test.ts
git commit -m "feat(orchestrator/tools): port base+rag+data tool clients to TS"
Task 20: Translate policy/{defaults,schema,store}.py → lib/orchestrator/policy/
Files:
- Reference: tmp/orchestrator/src/orchestrator/policy/defaults.py (70), schema.py (116), store.py (148)
- Create: apps/public-web/src/lib/orchestrator/policy/defaults.ts
- Create: apps/public-web/src/lib/orchestrator/policy/schema.ts
- Create: apps/public-web/src/lib/orchestrator/policy/store.ts
- Create: apps/public-web/tests/orchestrator/policy-store.test.ts
- [ ] Step 1: Open the Python source files in your editor
Read tmp/orchestrator/src/orchestrator/policy/defaults.py and copy DEFAULT_POLICY (a dict literal) into a TS object. Read schema.py and translate the Pydantic models into TS interfaces. Read store.py and translate the SQLite CRUD class.
- [ ] Step 2: Write
policy/schema.ts
export interface PolicyDoc {
id: string;
name: string;
version: number;
is_active: boolean;
prompt_injection?: string;
refusal_categories: string[];
sql_allow_tables?: string[];
rate_limit_per_minute?: number;
// mirror remaining fields exactly from schema.py's PolicyDoc
}
(Open tmp/orchestrator/src/orchestrator/policy/schema.py and add any missing fields verbatim, converting types: str → string, int → number, bool → boolean, list[T] → T[], Optional[T] → T | undefined.)
- [ ] Step 3: Write
policy/defaults.ts
import type { PolicyDoc } from "./schema";
export const DEFAULT_POLICY: PolicyDoc = {
// copy field-for-field from tmp/orchestrator/src/orchestrator/policy/defaults.py
};
- [ ] Step 4: Write
policy/store.ts
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_POLICY } from "./defaults";
import type { PolicyDoc } from "./schema";
export class PolicyStore {
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 policies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version INTEGER NOT NULL,
is_active INTEGER NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
const row = this.db.prepare("SELECT id FROM policies WHERE is_active=1").get();
if (!row) {
this.upsert(DEFAULT_POLICY);
}
}
list(): PolicyDoc[] {
const rows = this.db.prepare("SELECT payload FROM policies ORDER BY version DESC").all() as
| { payload: string }[];
return rows.map((r) => JSON.parse(r.payload) as PolicyDoc);
}
get(id: string): PolicyDoc | null {
const row = this.db.prepare("SELECT payload FROM policies WHERE id=?").get(id) as
| { payload: string }
| undefined;
return row ? (JSON.parse(row.payload) as PolicyDoc) : null;
}
getActive(): PolicyDoc {
const row = this.db.prepare("SELECT payload FROM policies WHERE is_active=1").get() as
| { payload: string }
| undefined;
if (!row) return DEFAULT_POLICY;
return JSON.parse(row.payload) as PolicyDoc;
}
upsert(doc: PolicyDoc): void {
const tx = this.db.transaction((d: PolicyDoc) => {
if (d.is_active) {
this.db.prepare("UPDATE policies SET is_active=0 WHERE is_active=1").run();
}
this.db
.prepare(
"INSERT OR REPLACE INTO policies (id,name,version,is_active,payload,updated_at) VALUES (?,?,?,?,?,?)",
)
.run(
d.id,
d.name,
d.version,
d.is_active ? 1 : 0,
JSON.stringify(d),
new Date().toISOString(),
);
});
tx(doc);
}
delete(id: string): void {
this.db.prepare("DELETE FROM policies WHERE id=?").run(id);
}
}
- [ ] Step 5: Write the test
import { describe, it, expect } from "vitest";
import { tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { PolicyStore } from "../../src/lib/orchestrator/policy/store";
import { DEFAULT_POLICY } from "../../src/lib/orchestrator/policy/defaults";
function tempPath() {
return join(mkdtempSync(join(tmpdir(), "pol-")), "p.sqlite");
}
describe("PolicyStore", () => {
it("seeds the default policy on first init", () => {
const s = new PolicyStore(tempPath());
expect(s.getActive().id).toBe(DEFAULT_POLICY.id);
});
it("upserts + retrieves", () => {
const s = new PolicyStore(tempPath());
s.upsert({ ...DEFAULT_POLICY, id: "p2", version: 2, is_active: true });
expect(s.getActive().id).toBe("p2");
expect(s.list().length).toBeGreaterThanOrEqual(2);
});
it("only one active at a time", () => {
const s = new PolicyStore(tempPath());
s.upsert({ ...DEFAULT_POLICY, id: "p3", version: 3, is_active: true });
s.upsert({ ...DEFAULT_POLICY, id: "p4", version: 4, is_active: true });
expect(s.getActive().id).toBe("p4");
expect(s.list().filter((p) => p.is_active).length).toBe(1);
});
});
- [ ] Step 6: Run tests
pnpm vitest run tests/orchestrator/policy-store.test.ts
Expected: 3/3 pass.
- [ ] Step 7: Commit
git add apps/public-web/src/lib/orchestrator/policy apps/public-web/tests/orchestrator/policy-store.test.ts
git commit -m "feat(orchestrator/policy): port Policy schema, defaults, and SQLite store to TS"
Task 21: Translate guards/{input_patterns,categories}.py + sql_guard/
Files:
- Reference: tmp/orchestrator/src/orchestrator/guards/input_patterns.py (61), categories.py (160), sql_guard/validator.py (121), sql_guard/result_filter.py (73)
- Create: apps/public-web/src/lib/orchestrator/guards/input-patterns.ts
- Create: apps/public-web/src/lib/orchestrator/guards/categories.ts
- Create: apps/public-web/src/lib/orchestrator/sql-guard/validator.ts
- Create: apps/public-web/src/lib/orchestrator/sql-guard/result-filter.ts
- Create: apps/public-web/tests/orchestrator/guards.test.ts
These are mostly mechanical: regex tables, classification helpers, SQL keyword checks. Translation rule: Python re.compile(r"...") → TS /.../i literal; Python list[str] → TS readonly string[]; helper function names preserved.
- [ ] Step 1: Translate
guards/input-patterns.ts— port theRefusaldataclass + the regex array + thematchfunction.
export interface Refusal {
kind: "refusal";
reason: string;
}
// Copy each REFUSAL_PATTERN regex literally from input_patterns.py (Python r"..." → TS /.../i)
const REFUSAL_PATTERNS: { pattern: RegExp; reason: string }[] = [
// { pattern: /^.../i, reason: "..." },
];
export function matchRefusal(message: string): Refusal | null {
for (const { pattern, reason } of REFUSAL_PATTERNS) {
if (pattern.test(message)) return { kind: "refusal", reason };
}
return null;
}
(Open tmp/orchestrator/src/orchestrator/guards/input_patterns.py and fill in the patterns array verbatim.)
- [ ] Step 2: Translate
guards/categories.ts— port the category-classification helper.
(Same approach: open categories.py, port the dictionary of categories → keyword arrays + the classify function.)
- [ ] Step 3: Translate
sql-guard/validator.ts— port the SQL safety check.
(Open sql_guard/validator.py — these are deny-list keyword checks. Mechanical translation.)
-
[ ] Step 4: Translate
sql-guard/result-filter.ts— port row-level redaction. -
[ ] Step 5: Write tests for each (3-5 cases per module, covering happy path + refusal triggers)
import { describe, it, expect } from "vitest";
import { matchRefusal } from "../../src/lib/orchestrator/guards/input-patterns";
describe("matchRefusal", () => {
it("returns null for benign input", () => {
expect(matchRefusal("Apa itu PT?")).toBeNull();
});
// Add one test per REFUSAL_PATTERN copied in Step 1
});
- [ ] Step 6: Run tests + commit
pnpm vitest run tests/orchestrator/guards.test.ts
git add apps/public-web/src/lib/orchestrator/guards apps/public-web/src/lib/orchestrator/sql-guard apps/public-web/tests/orchestrator/guards.test.ts
git commit -m "feat(orchestrator/guards): port input-pattern refusal + SQL guards to TS"
Task 22: Translate steps/plan.py → lib/orchestrator/steps/plan.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/steps/plan.py (92 lines)
- Create: apps/public-web/src/lib/orchestrator/steps/plan.ts
- Create: apps/public-web/tests/orchestrator/steps-plan.test.ts
- [ ] Step 1: Write the test
import { describe, it, expect, vi } from "vitest";
import { plan } from "../../src/lib/orchestrator/steps/plan";
import { LlmClient } from "../../src/lib/orchestrator/llm/client";
import { DEFAULT_POLICY } from "../../src/lib/orchestrator/policy/defaults";
describe("plan step", () => {
it("returns Refusal for input matching a refusal pattern", async () => {
const llm = {} as LlmClient;
const r = await plan({
history: [],
message: "<INPUT KNOWN TO MATCH A REFUSAL PATTERN FROM Task 21>",
availableTools: ["rag"],
llm,
policy: DEFAULT_POLICY,
});
expect("kind" in r && r.kind === "refusal").toBe(true);
});
it("returns Plan when LLM emits valid JSON", async () => {
const llm = {
completeJson: vi
.fn()
.mockResolvedValue({ reasoning: "r", calls: [{ tool: "rag", prompt: "q" }] }),
} as unknown as LlmClient;
const r = await plan({
history: [],
message: "Apa itu PT?",
availableTools: ["rag"],
llm,
policy: DEFAULT_POLICY,
});
expect("calls" in r && r.calls.length).toBe(1);
});
});
-
[ ] Step 2: Run — expect FAIL
-
[ ] Step 3: Write
steps/plan.ts
import type { ChatMessage, LlmClient } from "../llm/client";
import type { Plan, ToolCall, PlanOrRefusal } from "@ahu/orchestrator-types";
import type { PolicyDoc } from "../policy/schema";
import { matchRefusal } from "../guards/input-patterns";
const SYSTEM_PROMPT = `<COPY VERBATIM from tmp/orchestrator/src/orchestrator/steps/plan.py
the SYSTEM string near the top — port the Indonesian instructions exactly>`;
function buildUserPrompt(history: { role: string; content: string }[], message: string, availableTools: string[]) {
return [
`Available tools: ${availableTools.join(", ")}`,
`History: ${JSON.stringify(history.slice(-10))}`,
`Question: ${message}`,
'Output JSON: {"reasoning":"...","calls":[{"tool":"...","prompt":"..."}]}',
].join("\n\n");
}
export async function plan(args: {
history: { role: string; content: string }[];
message: string;
availableTools: string[];
llm: LlmClient;
policy: PolicyDoc;
}): Promise<PlanOrRefusal> {
const refusal = matchRefusal(args.message);
if (refusal) return refusal;
const sys: ChatMessage = { role: "system", content: SYSTEM_PROMPT };
const usr: ChatMessage = {
role: "user",
content: buildUserPrompt(args.history, args.message, args.availableTools),
};
const raw = await args.llm.completeJson<{ reasoning?: string; calls?: { tool?: string; prompt?: string }[] }>([sys, usr]);
const calls: ToolCall[] = (raw.calls ?? [])
.filter((c): c is { tool: string; prompt: string } => typeof c.tool === "string" && typeof c.prompt === "string")
.filter((c) => args.availableTools.includes(c.tool));
const planObj: Plan = { reasoning: raw.reasoning ?? "", calls };
return planObj;
}
(Open tmp/orchestrator/src/orchestrator/steps/plan.py and copy the Indonesian system prompt string verbatim into SYSTEM_PROMPT.)
- [ ] Step 4: Run tests + commit
pnpm vitest run tests/orchestrator/steps-plan.test.ts
git add apps/public-web/src/lib/orchestrator/steps/plan.ts apps/public-web/tests/orchestrator/steps-plan.test.ts
git commit -m "feat(orchestrator/steps): port plan step + Indonesian system prompt to TS"
Task 23: Translate steps/execute.py → lib/orchestrator/steps/execute.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/steps/execute.py (24 lines)
- Create: apps/public-web/src/lib/orchestrator/steps/execute.ts
- Create: apps/public-web/tests/orchestrator/steps-execute.test.ts
- [ ] Step 1: Write test
import { describe, it, expect, vi } from "vitest";
import { execute } from "../../src/lib/orchestrator/steps/execute";
describe("execute step", () => {
it("runs all tool calls in parallel and returns ToolResults", async () => {
const registry = {
rag: vi.fn().mockResolvedValue({ tool: "rag", prompt: "p", ok: true, duration_ms: 1 }),
data: vi.fn().mockResolvedValue({ tool: "data", prompt: "q", ok: true, duration_ms: 1 }),
};
const plan = { reasoning: "", calls: [{ tool: "rag", prompt: "p" }, { tool: "data", prompt: "q" }] };
const out = await execute(plan, registry, { perToolTimeoutSec: 10 });
expect(out.length).toBe(2);
expect(registry.rag).toHaveBeenCalledOnce();
expect(registry.data).toHaveBeenCalledOnce();
});
it("returns an error ToolResult when a tool throws", async () => {
const registry = { rag: vi.fn().mockRejectedValue(new Error("boom")) };
const plan = { reasoning: "", calls: [{ tool: "rag", prompt: "p" }] };
const out = await execute(plan, registry, { perToolTimeoutSec: 10 });
expect(out[0].ok).toBe(false);
expect(out[0].error).toBe("boom");
});
});
- [ ] Step 2: Write
steps/execute.ts
import type { Plan, ToolResult } from "@ahu/orchestrator-types";
import type { ToolFn } from "../tools/base";
export async function execute(
plan: Plan,
registry: Record<string, ToolFn>,
opts: { perToolTimeoutSec: number },
): Promise<ToolResult[]> {
const tasks = plan.calls.map(async (call): Promise<ToolResult> => {
const fn = registry[call.tool];
if (!fn) {
return { tool: call.tool, prompt: call.prompt, ok: false, error: "unknown tool", duration_ms: 0 };
}
try {
return await fn(call.prompt);
} catch (e) {
return {
tool: call.tool,
prompt: call.prompt,
ok: false,
error: e instanceof Error ? e.message : String(e),
duration_ms: 0,
};
}
});
return Promise.all(tasks);
}
- [ ] Step 3: Run + commit
pnpm vitest run tests/orchestrator/steps-execute.test.ts
git add apps/public-web/src/lib/orchestrator/steps/execute.ts apps/public-web/tests/orchestrator/steps-execute.test.ts
git commit -m "feat(orchestrator/steps): port execute step (parallel tool fan-out) to TS"
Task 24: Translate steps/evaluate.py → lib/orchestrator/steps/evaluate.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/steps/evaluate.py (68 lines)
- Create: apps/public-web/src/lib/orchestrator/steps/evaluate.ts
- Create: apps/public-web/tests/orchestrator/steps-evaluate.test.ts
- [ ] Step 1: Write test
import { describe, it, expect, vi } from "vitest";
import { evaluate } from "../../src/lib/orchestrator/steps/evaluate";
import type { LlmClient } from "../../src/lib/orchestrator/llm/client";
describe("evaluate step", () => {
it("returns sufficient=false with `missing` when judge enabled and LLM says insufficient", async () => {
const llm = {
completeJson: vi.fn().mockResolvedValue({ sufficient: false, missing: "year" }),
} as unknown as LlmClient;
const v = await evaluate("Q", [{ tool: "rag", prompt: "p", ok: true, duration_ms: 1 }], llm, {
ragSufficiency: 0.6,
enableJudge: true,
});
expect(v.sufficient).toBe(false);
expect(v.missing).toBe("year");
});
it("returns sufficient=true when judge disabled and rag ok", async () => {
const llm = {} as LlmClient;
const v = await evaluate("Q", [{ tool: "rag", prompt: "p", ok: true, response: "x", duration_ms: 1 }], llm, {
ragSufficiency: 0.6,
enableJudge: false,
});
expect(v.sufficient).toBe(true);
});
});
- [ ] Step 2: Write
steps/evaluate.ts
(Open tmp/orchestrator/src/orchestrator/steps/evaluate.py and port the heuristic + judge logic.)
import type { LlmClient, ChatMessage } from "../llm/client";
import type { ToolResult } from "@ahu/orchestrator-types";
export interface Verdict {
sufficient: boolean;
missing: string;
}
const JUDGE_SYSTEM = `<COPY from evaluate.py JUDGE system prompt verbatim>`;
export async function evaluate(
question: string,
results: ToolResult[],
llm: LlmClient,
opts: { ragSufficiency: number; enableJudge: boolean },
): Promise<Verdict> {
const allOk = results.length > 0 && results.every((r) => r.ok);
if (!allOk) return { sufficient: false, missing: "tool errors" };
if (!opts.enableJudge) return { sufficient: true, missing: "" };
const sys: ChatMessage = { role: "system", content: JUDGE_SYSTEM };
const usr: ChatMessage = {
role: "user",
content: `Question: ${question}\n\nResults:\n${results
.map((r) => `[${r.tool}] ${r.response ?? ""}`)
.join("\n")}\n\nOutput JSON: {"sufficient":true|false,"missing":"..."}`,
};
const raw = await llm.completeJson<{ sufficient?: boolean; missing?: string }>([sys, usr]);
return { sufficient: !!raw.sufficient, missing: raw.missing ?? "" };
}
- [ ] Step 3: Run + commit
pnpm vitest run tests/orchestrator/steps-evaluate.test.ts
git add apps/public-web/src/lib/orchestrator/steps/evaluate.ts apps/public-web/tests/orchestrator/steps-evaluate.test.ts
git commit -m "feat(orchestrator/steps): port evaluate step (judge + heuristic) to TS"
Task 25: Translate steps/compose.py → lib/orchestrator/steps/compose.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/steps/compose.py (61 lines)
- Create: apps/public-web/src/lib/orchestrator/steps/compose.ts
- Create: apps/public-web/tests/orchestrator/steps-compose.test.ts
- [ ] Step 1: Write test
import { describe, it, expect, vi } from "vitest";
import { compose } from "../../src/lib/orchestrator/steps/compose";
import type { LlmClient } from "../../src/lib/orchestrator/llm/client";
async function* streamFrom(chunks: string[]) {
for (const c of chunks) yield c;
}
describe("compose step", () => {
it("emits delta events from stream + final references", async () => {
const llm = {
stream: vi.fn().mockReturnValue(streamFrom(["He", "llo"])),
} as unknown as LlmClient;
const events: { kind: string; text?: string; references?: unknown[] }[] = [];
for await (const ev of compose(
"Q",
[{ tool: "rag", prompt: "p", ok: true, response: "x", references: [{ title: "t", source: "s" }], duration_ms: 1 }],
"",
llm,
)) {
events.push(ev);
}
const deltas = events.filter((e) => e.kind === "delta").map((e) => e.text);
expect(deltas.join("")).toBe("Hello");
const refs = events.find((e) => e.kind === "references");
expect(refs?.references?.length).toBe(1);
});
});
- [ ] Step 2: Write
steps/compose.ts
import type { LlmClient, ChatMessage } from "../llm/client";
import type { Reference, ToolResult } from "@ahu/orchestrator-types";
export type ComposeEvent =
| { kind: "delta"; text: string }
| { kind: "references"; references: Reference[] };
const COMPOSE_SYSTEM = `<COPY from compose.py SYSTEM prompt verbatim, including
the Indonesian writing-style instructions>`;
export async function* compose(
question: string,
results: ToolResult[],
missing: string,
llm: LlmClient,
): AsyncGenerator<ComposeEvent> {
const ctxParts = results.map((r) => `[${r.tool}] ${r.response ?? ""}`).join("\n\n");
const sys: ChatMessage = { role: "system", content: COMPOSE_SYSTEM };
const usr: ChatMessage = {
role: "user",
content: [
`Pertanyaan: ${question}`,
`Konteks dari alat:\n${ctxParts}`,
missing ? `Catatan: data berikut tidak ditemukan: ${missing}` : "",
]
.filter(Boolean)
.join("\n\n"),
};
for await (const delta of llm.stream([sys, usr])) {
yield { kind: "delta", text: delta };
}
const refs = results.flatMap((r) => r.references ?? []);
yield { kind: "references", references: refs };
}
(Copy the Indonesian COMPOSE_SYSTEM prompt from tmp/orchestrator/src/orchestrator/steps/compose.py.)
- [ ] Step 3: Run + commit
pnpm vitest run tests/orchestrator/steps-compose.test.ts
git add apps/public-web/src/lib/orchestrator/steps/compose.ts apps/public-web/tests/orchestrator/steps-compose.test.ts
git commit -m "feat(orchestrator/steps): port compose step (streaming + references) to TS"
Task 26: Translate orchestrate.py → lib/orchestrator/orchestrate.ts
Files:
- Reference: tmp/orchestrator/src/orchestrator/orchestrate.py (189 lines)
- Create: apps/public-web/src/lib/orchestrator/orchestrate.ts
- Create: apps/public-web/tests/orchestrator/orchestrate.test.ts
- [ ] Step 1: Write the integration test (mocks every step, asserts event order)
import { describe, it, expect, vi } from "vitest";
vi.mock("../../src/lib/orchestrator/steps/plan", () => ({
plan: vi.fn().mockResolvedValue({ reasoning: "r", calls: [{ tool: "rag", prompt: "p" }] }),
}));
vi.mock("../../src/lib/orchestrator/steps/execute", () => ({
execute: vi.fn().mockResolvedValue([{ tool: "rag", prompt: "p", ok: true, response: "x", duration_ms: 1 }]),
}));
vi.mock("../../src/lib/orchestrator/steps/evaluate", () => ({
evaluate: vi.fn().mockResolvedValue({ sufficient: true, missing: "" }),
}));
vi.mock("../../src/lib/orchestrator/steps/compose", () => ({
compose: async function* () {
yield { kind: "delta", text: "Hello" };
yield { kind: "references", references: [] };
},
}));
import { runOrchestrator } from "../../src/lib/orchestrator/orchestrate";
describe("runOrchestrator", () => {
it("emits the canonical event sequence", async () => {
const events: string[] = [];
for await (const chunk of runOrchestrator({
surface: "public",
history: [],
message: "Halo",
sessionId: "s1",
})) {
events.push(chunk);
}
const names = events.map((e) => e.split("\n")[0]);
expect(names).toEqual([
"event: status",
"event: status",
"event: content",
"event: references",
"event: done",
]);
});
});
- [ ] Step 2: Write
orchestrate.ts— the main state machine
import { ConfigStore } from "./config";
import { PolicyStore } from "./policy/store";
import { LlmClient } from "./llm/client";
import { RagTool } from "./tools/rag";
import { DataTool } from "./tools/data";
import { plan } from "./steps/plan";
import { execute } from "./steps/execute";
import { evaluate } from "./steps/evaluate";
import { compose } from "./steps/compose";
import { isRefusal, type Plan as PlanT } from "@ahu/orchestrator-types";
import * as sse from "./sse";
export interface OrchestrateRequest {
surface: "public" | "staff";
history: { role: string; content: string }[];
message: string;
sessionId: string;
}
function statusFor(p: PlanT): string {
const tools = new Set(p.calls.map((c) => c.tool));
if (tools.has("rag") && (tools.has("public_data") || tools.has("staff_data")))
return "Mencari dokumen dan memeriksa data...";
if (tools.has("rag")) return "Mencari dokumen...";
return "Memeriksa data...";
}
function env(name: string, fallback?: string): string {
const v = process.env[name];
if (v) return v;
if (fallback !== undefined) return fallback;
throw new Error(`missing required env ${name}`);
}
function llms() {
const planning = new LlmClient(env("MODEL_GATEWAY_URL"), env("LLM_PLANNING_MODEL"));
const synthesis = new LlmClient(env("SYNTHESIS_GATEWAY_URL", env("MODEL_GATEWAY_URL")), env("LLM_SYNTHESIS_MODEL"));
return { planning, synthesis };
}
function tools(surface: "public" | "staff", policy: import("./policy/schema").PolicyDoc, cfg: import("./config").Config) {
const rag = new RagTool(env("RAG_BASE_URL"), cfg.per_tool_timeout_seconds);
const registry: Record<string, (p: string) => Promise<import("@ahu/orchestrator-types").ToolResult>> = {
rag: (p) => rag.call(p),
};
if (surface === "public" && cfg.enable_public_data) {
const data = new DataTool(env("DATA_PUBLIC_BASE_URL"), env("DATA_PUBLIC_AGENT_ID"), "public_data", cfg.per_tool_timeout_seconds);
registry["public_data"] = (p) => data.call(p, { policy: { prompt_injection: policy.prompt_injection } });
} else if (surface === "staff" && cfg.enable_staff_data) {
const data = new DataTool(env("DATA_STAFF_BASE_URL"), env("DATA_STAFF_AGENT_ID"), "staff_data", cfg.per_tool_timeout_seconds);
registry["staff_data"] = (p) => data.call(p, { policy: { prompt_injection: policy.prompt_injection } });
}
return { registry, available: Object.keys(registry) };
}
export async function* runOrchestrator(req: OrchestrateRequest): AsyncGenerator<string> {
try {
const cfg = new ConfigStore(env("ORCHESTRATOR_CONFIG_DB", "/data/config.sqlite")).load();
const policyStore = new PolicyStore(env("ORCHESTRATOR_POLICY_DB", "/data/policy.sqlite"));
const policy = policyStore.getActive();
const { planning, synthesis } = llms();
const { registry, available } = tools(req.surface, policy, cfg);
const deadline = Date.now() + cfg.wall_clock_timeout_seconds * 1000;
const p = await plan({ history: req.history, message: req.message, availableTools: available, llm: planning, policy });
if (isRefusal(p)) {
yield sse.content(p.reason);
yield sse.done();
return;
}
yield sse.status(statusFor(p));
let results = await execute(p, registry, { perToolTimeoutSec: cfg.per_tool_timeout_seconds });
let rounds = 1;
let verdict = await evaluate(req.message, results, planning, {
ragSufficiency: cfg.rag_sufficiency_threshold,
enableJudge: cfg.enable_llm_judge,
});
while (!verdict.sufficient && rounds < cfg.max_tool_call_rounds && Date.now() < deadline) {
yield sse.status("Memperdalam pencarian...");
const raw = await planning.completeJson<{ calls?: { tool?: string; prompt?: string }[] }>([
{ role: "system", content: 'Output JSON: {"calls":[{"tool":"...","prompt":"..."}]}' },
{
role: "user",
content: `Pertanyaan: ${req.message}\nPlan sebelumnya tidak cukup. Yang kurang: ${verdict.missing}. Buat ulang plan dengan prompt yang lebih spesifik.`,
},
]);
const calls2 = (raw.calls ?? []).filter(
(c): c is { tool: string; prompt: string } =>
typeof c.tool === "string" && typeof c.prompt === "string" && available.includes(c.tool),
);
if (calls2.length === 0) break;
const r2 = await execute({ reasoning: "re-plan", calls: calls2 }, registry, {
perToolTimeoutSec: cfg.per_tool_timeout_seconds,
});
results = [...results, ...r2];
rounds += 1;
verdict = await evaluate(req.message, results, planning, {
ragSufficiency: cfg.rag_sufficiency_threshold,
enableJudge: cfg.enable_llm_judge,
});
}
yield sse.status("Menyusun jawaban...");
const missing = verdict.sufficient ? "" : verdict.missing;
const refs: import("@ahu/orchestrator-types").Reference[] = [];
for await (const ev of compose(req.message, results, missing, synthesis)) {
if (ev.kind === "delta") yield sse.content(ev.text);
else if (ev.kind === "references") refs.push(...ev.references);
}
yield sse.references(refs);
yield sse.done();
} catch (e) {
yield sse.error(`orchestrator failure: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`);
yield sse.done();
}
}
- [ ] Step 3: Run + commit
pnpm vitest run tests/orchestrator/orchestrate.test.ts
git add apps/public-web/src/lib/orchestrator/orchestrate.ts apps/public-web/tests/orchestrator/orchestrate.test.ts
git commit -m "feat(orchestrator): port main orchestrate state machine to TS (Plan→Execute→Judge→Re-plan→Compose)"
Phase 1D — Wire apps/public-web API routes
Task 27: Add the /api/orchestrate route handler
Files:
- Create: apps/public-web/src/app/api/orchestrate/route.ts
- Create: apps/public-web/tests/api/orchestrate.test.ts
- [ ] Step 1: Write the route handler
// apps/public-web/src/app/api/orchestrate/route.ts
import { runOrchestrator, type OrchestrateRequest } from "@/lib/orchestrator/orchestrate";
export const runtime = "nodejs";
export async function POST(req: Request): Promise<Response> {
let body: OrchestrateRequest;
try {
body = (await req.json()) as OrchestrateRequest;
} catch {
return new Response("invalid json", { status: 400 });
}
if (!body.surface || !body.message || !body.sessionId) {
return new Response("missing surface|message|sessionId", { status: 400 });
}
if (body.surface !== "public" && body.surface !== "staff") {
return new Response("invalid surface", { status: 400 });
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
try {
for await (const chunk of runOrchestrator(body)) {
controller.enqueue(enc.encode(chunk));
}
} catch (e) {
controller.enqueue(enc.encode(`event: error\ndata: ${JSON.stringify({ message: String(e) })}\n\n`));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
- [ ] Step 2: Write the test (smoke — verifies basic 4xx behavior)
// apps/public-web/tests/api/orchestrate.test.ts
import { describe, it, expect } from "vitest";
import { POST } from "../../src/app/api/orchestrate/route";
function jsonReq(body: unknown) {
return new Request("http://x/api/orchestrate", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
describe("/api/orchestrate", () => {
it("400 on missing fields", async () => {
const r = await POST(jsonReq({}));
expect(r.status).toBe(400);
});
it("400 on invalid surface", async () => {
const r = await POST(jsonReq({ surface: "bad", message: "x", sessionId: "s" }));
expect(r.status).toBe(400);
});
});
- [ ] Step 3: Run + commit
pnpm vitest run tests/api/orchestrate.test.ts
git add apps/public-web/src/app/api/orchestrate apps/public-web/tests/api/orchestrate.test.ts
git commit -m "feat(public-web): add /api/orchestrate route handler streaming SSE from runOrchestrator"
Task 28: Add /api/admin/policy/* CRUD routes (writes to PolicyStore)
Files:
- Create: apps/internal-web/src/app/api/admin/policy/route.ts (GET list, POST upsert)
- Create: apps/internal-web/src/app/api/admin/policy/[id]/route.ts (GET one, DELETE)
- Note: admin lives on internal-web; the PolicyStore SQLite path is shared via volume mount so the orchestrator in public-web reads the latest.
- [ ] Step 1: Write list/upsert route
// apps/internal-web/src/app/api/admin/policy/route.ts
import { NextResponse } from "next/server";
import { PolicyStore } from "@/lib/policy-store";
export const runtime = "nodejs";
function store() {
return new PolicyStore(process.env.ORCHESTRATOR_POLICY_DB ?? "/data/policy.sqlite");
}
export async function GET() {
return NextResponse.json({ items: store().list() });
}
export async function POST(req: Request) {
const body = await req.json();
store().upsert(body);
return NextResponse.json({ ok: true });
}
- [ ] Step 2: Write per-id route
// apps/internal-web/src/app/api/admin/policy/[id]/route.ts
import { NextResponse } from "next/server";
import { PolicyStore } from "@/lib/policy-store";
export const runtime = "nodejs";
function store() {
return new PolicyStore(process.env.ORCHESTRATOR_POLICY_DB ?? "/data/policy.sqlite");
}
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const doc = store().get(id);
if (!doc) return new NextResponse("not found", { status: 404 });
return NextResponse.json(doc);
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
store().delete(id);
return NextResponse.json({ ok: true });
}
- [ ] Step 3: Add the import path helper
Create apps/internal-web/src/lib/policy-store.ts that re-exports the same PolicyStore from apps/public-web/src/lib/orchestrator/policy/store.ts. Alternative: move PolicyStore to a shared package. Simpler: copy the file (it's standalone) into internal-web's src/lib/policy-store.ts so both apps use it but neither imports cross-app code.
cp /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-web/src/lib/orchestrator/policy/store.ts \
/home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-web/src/lib/policy-store.ts
Adjust the relative imports inside the copy to point at internal-web local files.
- [ ] Step 4: Commit
git add apps/internal-web/src/app/api/admin/policy apps/internal-web/src/lib/policy-store.ts
git commit -m "feat(internal-web): admin policy CRUD endpoints (writes shared SQLite read by public-web)"
Task 29: Delete tmp/orchestrator/
Files:
- Delete: tmp/orchestrator/
- [ ] Step 1: Verify all 8 Python orchestrator modules have TS counterparts
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-web/src/lib/orchestrator/
Expected: directories steps, tools, policy, guards, sql-guard, llm and files orchestrate.ts, sse.ts, config.ts.
- [ ] Step 2: Run the full public-web test suite — all green
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
pnpm --filter @ahu/public-web test
Expected: all tests pass.
- [ ] Step 3: Delete tmp/
git rm -r tmp/orchestrator
rmdir tmp 2>/dev/null
- [ ] Step 4: Commit
git commit -m "chore: delete tmp/orchestrator (Python source) — TS translation complete"
Phase 1E — Duplicate + isolate the Dash agent
Task 30: Create packages/agno-base (shared Python scaffolding)
Files:
- Create: packages/agno-base/pyproject.toml
- Create: packages/agno-base/agno_base/__init__.py
- Create: packages/agno-base/agno_base/prompts/{__init__,base,public,internal}.py
- Create: packages/agno-base/agno_base/client/vllm.py
- [ ] Step 1: Write
packages/agno-base/pyproject.toml
[project]
name = "agno-base"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27",
"pydantic>=2.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["agno_base"]
- [ ] Step 2: Read the existing dash prompt + identify the "internal" + "public" variants
grep -rn "INSTRUCTIONS" /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/dash/ | head
The current apps/internal-agent/dash/agents.py has an INSTRUCTIONS string. We'll split it into shared base + per-surface overrides.
- [ ] Step 3: Write
agno_base/prompts/base.py
"""Shared Dash agent instructions — common across public and internal surfaces."""
BASE_INSTRUCTIONS = """
You are Dash, an Indonesian data-analysis agent for Direktorat Jenderal AHU.
You answer questions about Indonesian legal entity data (PT, perseroan, fidusia, notaris,
BO, kewarganegaraan). You write SQL grounded in the provided knowledge layers and explain
results in plain Indonesian.
""".strip()
- [ ] Step 4: Write
agno_base/prompts/public.py
"""Restricted prompt for the public-facing Dash variant."""
from .base import BASE_INSTRUCTIONS
PUBLIC_RESTRICTIONS = """
SCOPE: Only answer questions that can be served by tables marked public in your knowledge.
Refuse PII lookups, individual-record queries, and queries about specific named persons
or entities. Aggregate-only. If a question asks for record-level detail, respond:
'Maaf, layanan publik hanya menyediakan statistik agregat.'
""".strip()
INSTRUCTIONS = f"{BASE_INSTRUCTIONS}\n\n{PUBLIC_RESTRICTIONS}"
- [ ] Step 5: Write
agno_base/prompts/internal.py
"""Full-access prompt for the staff-facing Dash variant."""
from .base import BASE_INSTRUCTIONS
INTERNAL_NOTES = """
You have full read access to internal tables. Always cite your sources by including
the table name in your reasoning. Staff users may ask for record-level detail.
""".strip()
INSTRUCTIONS = f"{BASE_INSTRUCTIONS}\n\n{INTERNAL_NOTES}"
- [ ] Step 6: Add
agno_base/__init__.py+prompts/__init__.py
# packages/agno-base/agno_base/__init__.py
__version__ = "0.0.0"
# packages/agno-base/agno_base/prompts/__init__.py
- [ ] Step 7: Sync uv workspace
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
uv sync --all-packages
Expected: .venv/ created at root; agno-base is installed in editable mode.
- [ ] Step 8: Commit
git add packages/agno-base
git commit -m "feat(agno-base): shared Python package with split public/internal Dash prompts"
Task 31: Duplicate apps/internal-agent into apps/public-agent
Files:
- Create: apps/public-agent/ (cloned from apps/internal-agent/)
- [ ] Step 1: Copy the tree
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p apps/public-agent
cp -r apps/internal-agent/{app,dash,db,scripts,tests,pyproject.toml,Dockerfile,requirements.txt,example.env} apps/public-agent/
# don't copy knowledge tarballs — they'll be remounted from infra/knowledge
rm -f apps/public-agent/dash-data.tar.gz apps/public-agent/dash-knowledge.tar.gz apps/public-agent/pgdata.tar.gz
- [ ] Step 2: Rename the package in
apps/public-agent/pyproject.toml
Open the file. Find the [project] name = "..." line. Change to name = "public-agent".
- [ ] Step 3: Patch
apps/public-agent/dash/agents.pyto import the public prompt
Open apps/public-agent/dash/agents.py. Find the line that imports or defines INSTRUCTIONS. Replace with:
from agno_base.prompts.public import INSTRUCTIONS
Remove any literal INSTRUCTIONS = "..." definition that's now superseded.
- [ ] Step 4: Patch
apps/internal-agent/dash/agents.pysymmetrically
from agno_base.prompts.internal import INSTRUCTIONS
- [ ] Step 5: Add
agno-baseas a dependency in both agent pyprojects
In both apps/{public,internal}-agent/pyproject.toml [project] dependencies, add "agno-base" (workspace will resolve it locally).
- [ ] Step 6: Sync + verify imports
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
uv sync --all-packages
uv run python -c "from agno_base.prompts.public import INSTRUCTIONS; print(len(INSTRUCTIONS))"
uv run python -c "from agno_base.prompts.internal import INSTRUCTIONS; print(len(INSTRUCTIONS))"
Expected: prints two non-zero integers.
- [ ] Step 7: Commit
git add apps/public-agent apps/internal-agent
git commit -m "feat(public-agent): duplicate from internal-agent + wire to shared agno_base prompts (public scope)"
Task 32: Move knowledge to infra/knowledge/{public,internal}/
Files:
- Move: apps/internal-agent/dash/knowledge/* → infra/knowledge/internal/
- Create: infra/knowledge/public/ (curated subset of internal)
- [ ] Step 1: Inventory the dash knowledge files
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/dash/knowledge/ 2>/dev/null
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/dash/knowledge/tables/ 2>/dev/null
- [ ] Step 2: Move tables/business/queries into the internal mount
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git mv apps/internal-agent/dash/knowledge/tables/* infra/knowledge/internal/tables/ 2>/dev/null || true
git mv apps/internal-agent/dash/knowledge/business/* infra/knowledge/internal/business/ 2>/dev/null || true
git mv apps/internal-agent/dash/knowledge/queries/* infra/knowledge/internal/queries/ 2>/dev/null || true
- [ ] Step 3: Curate a public knowledge subset
For each table JSON under infra/knowledge/internal/tables/, decide whether it's appropriate for public exposure. Rule of thumb: aggregate-friendly tables (statistik, ringkasan) yes; PII tables (badan_hukum_detail, notaris_kontak) no.
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
ls infra/knowledge/internal/tables/
For each table approved for public access:
cp infra/knowledge/internal/tables/<TABLE>.json infra/knowledge/public/tables/<TABLE>.json
(If the table needs row-level redaction at the column level, modify the public copy to exclude restricted columns from the schema — those columns are then invisible to the public agent's planning.)
-
[ ] Step 4: Mirror business rules + queries subset (curate same way)
-
[ ] Step 5: Update
apps/{public,internal}-agent/dash/paths.py(or equivalent)
Find the knowledge directory constant. Change to read from KNOWLEDGE_DIR env var:
import os
from pathlib import Path
KNOWLEDGE_DIR = Path(os.environ.get("KNOWLEDGE_DIR", "/app/dash/knowledge"))
- [ ] Step 6: Commit
git add infra/knowledge apps/internal-agent apps/public-agent
git commit -m "feat(knowledge): split into infra/knowledge/{public,internal} + read via KNOWLEDGE_DIR env"
Task 33: Add isolation-checking tests
Files:
- Create: tests/isolation/test_python_isolation.py
- Create: apps/public-web/tests/isolation/route-isolation.test.ts
- [ ] Step 1: Write Python isolation test
# tests/isolation/test_python_isolation.py
"""Verify the public-agent Python image cannot import internal-only modules
even though both apps share packages/agno-base."""
import importlib
import sys
import pytest
def test_public_agent_can_import_public_prompt():
m = importlib.import_module("agno_base.prompts.public")
assert m.INSTRUCTIONS
def test_public_agent_should_not_use_internal_prompt():
"""We don't prevent the import (same wheel), but a runtime assertion in
apps/public-agent/app/main.py should refuse to start if SURFACE != public."""
surface = sys.modules.get("app.main", None)
# If app.main has loaded, surface check passed
assert surface is None or getattr(surface, "SURFACE", "public") == "public"
def test_public_knowledge_dir_is_not_internal():
import os
kd = os.environ.get("KNOWLEDGE_DIR", "")
assert "internal" not in kd, f"public agent must not mount internal knowledge dir: {kd}"
- [ ] Step 2: Write TS route isolation test
// apps/public-web/tests/isolation/route-isolation.test.ts
import { describe, it, expect } from "vitest";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
describe("public-web route isolation", () => {
it("does not contain any /admin route", async () => {
const appDir = join(__dirname, "..", "..", "src", "app");
const top = await readdir(appDir);
expect(top).not.toContain("admin");
});
it("does not contain any /app (staff) route", async () => {
const appDir = join(__dirname, "..", "..", "src", "app");
const top = await readdir(appDir);
expect(top).not.toContain("app");
});
it("does not contain admin API routes", async () => {
const apiDir = join(__dirname, "..", "..", "src", "app", "api");
const top = await readdir(apiDir);
expect(top).not.toContain("admin");
});
});
- [ ] Step 3: Run + commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
uv run pytest tests/isolation/ -v
pnpm --filter @ahu/public-web vitest run tests/isolation/
git add tests/isolation apps/public-web/tests/isolation
git commit -m "test(isolation): verify public-web has no admin routes + public-agent uses public knowledge"
Task 34: Add a main.py surface guard to both agents
Files:
- Modify: apps/public-agent/app/main.py — add a startup check
- Modify: apps/internal-agent/app/main.py — add a startup check
- [ ] Step 1: Add the guard to
apps/public-agent/app/main.py
Open the file. Near the top (after imports), add:
import os
import sys
SURFACE = "public"
if os.environ.get("SURFACE", "public") != SURFACE:
print(f"FATAL: public-agent started with SURFACE={os.environ.get('SURFACE')!r}, refusing to run", file=sys.stderr)
sys.exit(1)
# Validate knowledge mount
_kd = os.environ.get("KNOWLEDGE_DIR", "")
if "internal" in _kd:
print(f"FATAL: public-agent must not mount internal knowledge: KNOWLEDGE_DIR={_kd!r}", file=sys.stderr)
sys.exit(1)
- [ ] Step 2: Add the guard to
apps/internal-agent/app/main.py
import os
import sys
SURFACE = "internal"
if os.environ.get("SURFACE", "internal") != SURFACE:
print(f"FATAL: internal-agent started with SURFACE={os.environ.get('SURFACE')!r}, refusing to run", file=sys.stderr)
sys.exit(1)
- [ ] Step 3: Commit
git add apps/public-agent/app/main.py apps/internal-agent/app/main.py
git commit -m "feat(agent): startup surface guard rejects misconfigured deployments"
Phase 1F — Parity + cleanup
Task 35: Run the existing eval against both agents to verify parity
Files:
- Reference: apps/internal-agent/dash/evals/run_model_comparison.py (or similar — look for existing eval scripts)
- Possibly modify: eval script to accept a --knowledge-dir flag
- [ ] Step 1: Find the eval script
ls /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent/dash/evals/
Expected: ahu_test_cases.py, run_model_comparison.py, run_latency_bench.py.
- [ ] Step 2: Run a smoke eval against internal-agent (parity to current production)
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/internal-agent
KNOWLEDGE_DIR=/home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/knowledge/internal \
MODEL_GATEWAY_URL=http://192.168.83.20:8000 \
SYNTHESIS_GATEWAY_URL=<Alibaba Cloud Model Studio endpoint from secrets> \
uv run python -m dash.evals.run_model_comparison --limit 5
Expected: produces a CSV/JSON output file. No regressions vs the historical baseline.
- [ ] Step 3: Run smoke eval against public-agent
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-agent
KNOWLEDGE_DIR=/home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/knowledge/public \
SURFACE=public \
MODEL_GATEWAY_URL=http://192.168.83.20:8000 \
SYNTHESIS_GATEWAY_URL=<Alibaba Cloud Model Studio endpoint from secrets> \
uv run python -m dash.evals.run_model_comparison --limit 5
Expected: aggregate-only queries pass; record-level queries return refusal messages.
- [ ] Step 4: Commit the eval outputs as a baseline artifact
mkdir -p docs/superpowers/parity-snapshots/2026-06-30
cp apps/internal-agent/eval-output*.json docs/superpowers/parity-snapshots/2026-06-30/ 2>/dev/null || true
cp apps/public-agent/eval-output*.json docs/superpowers/parity-snapshots/2026-06-30/ 2>/dev/null || true
git add docs/superpowers/parity-snapshots
git commit -m "docs(parity): baseline eval snapshots for internal-agent + public-agent post-split"
Task 36: Update top-level Makefile smoke targets
Files:
- Modify: Makefile
- [ ] Step 1: Add isolation + parity smoke targets
Append to the existing Makefile:
parity-smoke:
cd apps/internal-agent && KNOWLEDGE_DIR=$(PWD)/infra/knowledge/internal uv run python -m dash.evals.run_model_comparison --limit 5
cd apps/public-agent && KNOWLEDGE_DIR=$(PWD)/infra/knowledge/public SURFACE=public uv run python -m dash.evals.run_model_comparison --limit 5
verify-all:
pnpm typecheck
pnpm test
uv run pytest tests/isolation -v
$(MAKE) isolation-test
- [ ] Step 2: Run the full verify-all
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
make verify-all
Expected: every step passes.
- [ ] Step 3: Commit
git add Makefile
git commit -m "chore: Makefile parity-smoke + verify-all targets"
Task 37: Final commit + document the diff at end-of-Plan-A
Files:
- Create: docs/superpowers/plans/2026-06-30-monorepo-split-plan-a-completion-notes.md
- [ ] Step 1: Capture a short completion note
# Plan A completion notes (2026-06-30)
## State at end of Plan A
- Monorepo `ahu-ai-chatbot/` with 4 apps (`public-web`, `internal-web`, `public-agent`, `internal-agent`) and 6 packages.
- Public-web TS orchestrator passes unit tests; internal-agent + public-agent boot with their respective prompt modules.
- Knowledge split lives under `infra/knowledge/{public,internal}/`; agents read via `KNOWLEDGE_DIR`.
- `tmp/orchestrator/` deleted — Python orchestrator fully replaced.
- Isolation tests pass: `public-web` has no `/admin` or `/app` routes; `public-agent` startup guard rejects internal knowledge mount.
- Old sibling repos `ai-ahu-data-dash` + `ahu-chatbot-orchestrator` are NOT yet archived (Plan B Phase 3).
## What Plan B picks up
- Write `infra/compose.public.yaml` + `infra/compose.internal.yaml`
- Build Docker images `ahu-ai-chatbot-{public,internal}` + `ahu-ai-agent-{public,internal}`
- Staging deploy parallel to production
- DNS/reverse-proxy cutover on `chatbot-neo.val.id`
- Archive old repos
- [ ] Step 2: Commit
git add docs/superpowers/plans/2026-06-30-monorepo-split-plan-a-completion-notes.md
git commit -m "docs(plan-a): completion notes + handoff to Plan B"
End-of-Plan-A success criteria
Run these once Task 37 is committed:
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
make verify-all
All of the following must be true:
pnpm typecheck— green across all workspace packagespnpm test— green across all workspace packages (incl. ~40 orchestrator unit tests)uv run pytest tests/isolation -v— 3+ isolation tests passmake isolation-test— public-web has no admin routesmake parity-smoke— 5-case eval runs cleanly against both agentstmp/orchestrator/no longer existsinfra/knowledge/public/contains a curated subset ofinternal/- Both agents have surface guards in
app/main.py
Plan A is done when all 8 hold.