think
16px
820px

Monorepo Public/Internal Split — Design Spec

Date: 2026-06-30
Status: Approved, ready for implementation planning
Supersedes: the 3-repo topology described in 2026-05-21-orchestrator-and-public-data-design.md (the orchestrator design itself stays; only its hosting changes)

Goal

Collapse the current 3-repo system (ai-ahu-chatbot + ahu-chatbot-orchestrator + ai-ahu-data-dash) into a single monorepo (ahu-ai-chatbot), structured around the real architectural boundary — public vs internal — with physical isolation between the two so that internal data, prompts, and code paths cannot leak to public traffic.

Drivers

  • The 3-repo split is a historical accident, not a real architectural boundary. The real boundary is public vs internal.
  • Specs already live in the chatbot repo but reference code in sibling repos — constant drift.
  • Cross-repo dev requires coordinating 3 PRs, 3 deploys, 3 git histories. Mistakes cause version skew.
  • Single deploy artifact + one runbook is easier to operate.

Constraints

  • Model deployment (dual-model, transitional): planning runs on the local GPU server via vLLM (Qwen3.6-35B-A3B-FP8); synthesis runs on Alibaba Cloud (Qwen3.5-397B-A17B) as a transition state. Synthesis will move to the local GPU server once we have the 16× B200 GPUs (shared with other AHU products). No OpenAI/Anthropic/etc. in production paths. Both endpoints are OpenAI-compatible and reached through MODEL_GATEWAY_URL — flipping synthesis from Alibaba → local later is a config change, not a code change.
  • Physical isolation between public and internal: internal code must not compile into the public artifact. Different containers, different env, different networks, different data sources, different prompts.
  • Working system must keep working: chatbot-neo.val.id is live. Cutover plan must preserve service.
  • Indonesian-language UX: existing surface conventions (Tanya / Tanya Data / Konsol Admin) preserved.

Current state (3-repo)

  • ai-ahu-chatbot — Next.js 15 frontend, hosted as ahu-chatbot-dev container on Server 1 (0-GPU, 192.168.62.155). Routes: /tanya (public), /app/data (staff), /admin/* (staff). Talks to orchestrator + internal Dash directly.
  • ahu-chatbot-orchestrator — Python FastAPI, Server 2 (GPU, 192.168.83.20), port 8104. Plan→Execute→Judge→Compose loop. Routes public Tanya traffic to RAG (8110) + public Dash (8102).
  • ai-ahu-data-dash — Python FastAPI Agno SQL agent, deployed twice on Server 2 (dash-api-public:8102, dash-api-staff:8101) with different knowledge scopes.

Target topology

Single monorepo, 4 deployable apps, all hosted on Server 2 (co-located with vLLM, Milvus, embedding model).

ahu-ai-chatbot/
├── apps/
   ├── public-web/          # Next.js  Tanya + orchestrator API routes + SSE
   ├── internal-web/        # Next.js  Tanya Data + Konsol Admin
   ├── public-agent/        # Python  restricted Dash variant
   └── internal-agent/      # Python  full Dash variant
├── packages/
   ├── ui/                  # shared React components (chat, tables, charts, admin chrome)
   ├── streams/             # shared SSE/native-provider TS code
   ├── orchestrator-types/  # Plan/Tool/Event types shared FE  API routes
   ├── queue/               # BullMQ workers (background jobs)
   ├── agno-base/           # shared Python: tools, prompt scaffolding, vLLM client
   └── eval/                # eval harness
├── infra/
   ├── compose.public.yaml
   ├── compose.internal.yaml
   ├── compose.shared.yaml      # Redis, future model-gateway placeholder
   └── knowledge/
       ├── public/              # public Dash knowledge (restricted)
       └── internal/            # internal Dash knowledge (full)
└── docs/

Why 4 apps and not 5 or 3

  • The public orchestrator does pure HTTP coordination (vLLM via OpenAI-compatible HTTP, SSE encoding, tool routing to RAG/Dash, SQLite policy CRUD). No Python-specific dependency. Folded into Next.js API routes — shared types with the FE automatic, single SSE pipeline, no proxy hop.
  • Public and internal Next.js stay separate apps so internal routes never compile into the public bundle. Approach C (one Next.js, two configs) was rejected because it lets internal code paths exist inside the public binary.
  • Approach B (orchestrator + agent in one Python process) was rejected because it couples them and requires an up-front rewrite. Keeping them separate keeps the rewrite optional.

Isolation contract

The contract is enforced at deploy time. The orchestration framework here is "what code is in which image, what env each image gets".

What Public artifact Internal artifact
Docker image ahu-ai-chatbot-public (+ ahu-ai-agent-public) ahu-ai-chatbot-internal (+ ahu-ai-agent-internal)
Bundled routes /tanya/*, /api/orchestrate, /api/health /app/*, /admin/*, /api/data, /api/admin/*
Env: DB creds Phase 1: same internal DB + full creds (no public DB yet). Phase 2: PUBLIC_DB_URL with restricted creds. INTERNAL_DB_URL, full creds
Env: agent URL PUBLIC_AGENT_URL=http://public-agent:8000 INTERNAL_AGENT_URL=http://internal-agent:8000
Knowledge dir (read-only mount) infra/knowledge/public/ infra/knowledge/internal/
Docker network public-net + gateway-net + ingress internal-net + gateway-net
Internet ingress yes (via reverse proxy → chatbot-neo.val.id) no (LAN only)
Prompts packages/agno-base/prompts/public.py packages/agno-base/prompts/internal.py

Three isolations satisfied:

  • Data source — public-web has no DB env vars at all; public-agent has restricted-scope creds (Phase 2) + public-only knowledge mount.
  • Instructions — separate prompt modules per agent. Never co-loaded in the same process.
  • Flow — public goes through orchestrator (Plan/Execute/Judge/Compose); internal calls agent directly. Different code paths, different binaries.

Phase 1 honest caveat: because no public DB exists yet, the public agent reads from the internal DB in Phase 1. Protection that public users can't see internal data comes from (a) the restricted prompt in prompts/public.py + (b) the restricted tool surface loaded into public-agent — same posture as today's dash-api-public. The env-var shape is pre-wired for a PUBLIC_DB_URL flip with zero code changes when the public DB lands.

Data flow

Public turn (chatbot-neo.val.id/tanya):

Browser
  ↓ POST /api/orchestrate (SSE)
public-web (Next.js)
  ├─ lib/orchestrator/plan      → MODEL_GATEWAY_URL (planning model = local vLLM 3.6-35B, headers: X-Surface=public, X-Priority=planning)
  ├─ lib/orchestrator/execute   → public-rag-service:8110/search
  │                            → public-agent:8000/agents/data-agent/runs
  ├─ lib/orchestrator/judge     → MODEL_GATEWAY_URL (planning model)
  └─ lib/orchestrator/compose   → MODEL_GATEWAY_URL (synthesis model = Alibaba 3.5-397B today, local later)  → SSE chunks back

Internal turn (internal.local/app/data):

Browser
  ↓ POST /api/data (SSE)
internal-web (Next.js)
  └─ direct → internal-agent:8000/agents/data-agent/runs → MODEL_GATEWAY_URL

Forward-compat for shared GPU infrastructure

The chatbot monorepo does NOT build the GPU/model gateway — that's a separate platform service shared across AHU products (chatbot, doc-classifier, ocr-akta-notaris, doc-forensic). This monorepo just doesn't paint itself into a corner.

Reserved hooks (config slots today, drop-in tomorrow):

  1. MODEL_GATEWAY_URL (planning) + SYNTHESIS_GATEWAY_URL env vars — both OpenAI-compatible. Today: planning → local http://ahu-vllm:8000 (Qwen3.6-35B-A3B-FP8); synthesis → Alibaba Cloud Model Studio endpoint (Qwen3.5-397B-A17B). When synthesis moves on-prem, SYNTHESIS_GATEWAY_URL flips to the local gateway. When the shared model gateway lands, both flip to it. Zero code change either time.
  2. Standard request headers from orchestrator/agent → vLLM:
    - X-Tenant-Id: ahu-chatbot
    - X-Surface: public|internal
    - X-User-Id: <anon-bucket-or-staff-id>
    - X-Priority: planning|synthesis|eval
    vLLM ignores them today; gateway uses them for fairness + scheduling tomorrow.
  3. packages/queue/ (BullMQ on Redis) — day-1 use: nightly eval runs + knowledge re-ingestion workers. NOT user-facing GPU-queue UI (that belongs in the future gateway).
  4. SSE resume primitive — orchestrator writes Last-Event-ID checkpoints into Redis so the browser can reconnect mid-stream. Useful both for flaky networks today and for queue-position UI later.

Admin UI/UX

Admin lives only in internal-web. The public app ships zero admin routes — they don't exist in the public bundle.

Nav structure

Konsol Admin
├─ 🌐 Publik                    (config that affects public-agent / public surface)
   ├─ Knowledge Base            tables, business rules, query patterns (public scope)
   ├─ Prompt & Persona          public agent system prompt
   ├─ Public Policy             rate limits, anon override, redirect rules
   └─ RAG Corpus                public document ingestion (Tanya RAG)

├─ 🔒 Internal                  (config that affects internal-agent / staff surface)
   ├─ Knowledge Base            tables, business rules, query patterns (full scope)
   ├─ Prompt & Persona          internal agent system prompt
   └─ Schema Sync               reload-schema, DB introspection

├─ ⚙️ Bersama (Shared)
   ├─ Model Gateway             MODEL_GATEWAY_URL (planning, local), SYNTHESIS_GATEWAY_URL (Alibaba today, local later), preset switcher
   ├─ Provider Config           vLLM endpoint, fallback, headers
   └─ Background Jobs           BullMQ queues (evals, ingestion runs)

└─ 📊 Observasi
    ├─ Threads & Sessions        per-surface filter
    ├─ Eval Runs                 kick off + view history
    └─ Audit Log                 config changes, who/when

Visual cues (inherit existing .admin-shell aesthetic)

  • Page header has a tinted scope badge: 🌐 public (cool blue), 🔒 internal (warm amber), ⚙️ shared (neutral). Tinted left-border on cards reinforces scope.
  • Color is decorative, never the only signal — the badge text + icon are always visible.
  • Top-right of every admin page: a scope pill showing the current scope. Cmd+K palette has "Switch to Public Admin" / "Switch to Internal Admin".
  • Drift detection on Knowledge Base pages: when a table/business-rule exists in both scopes, show a small "drift detected" badge if their content differs.
  • Cross-scope copy actions (e.g. "promote this rule from internal → public") use the existing DiffConfirmDialog and explicitly show what becomes visible to public.

Auth posture

  • Internal-web has NextAuth + staff role gate (current setup).
  • New role: admin-public — can edit Public scope only.
  • Existing role: admin-internal — can edit Internal + Shared.
  • Audit log records {scope, who, what, when, before, after} for every mutation.

Route layout

apps/internal-web/app/admin/
├── public/
   ├── knowledge/
   ├── persona/
   ├── policy/         (current public-policy redesign lands here)
   └── rag/
├── internal/
   ├── knowledge/
   ├── persona/
   └── schema/
├── shared/
   ├── model-gateway/
   ├── provider/
   └── jobs/
└── observe/
    ├── threads/
    ├── evals/
    └── audit/

Shared admin chrome (cards, dialogs, chips) lives in packages/ui/admin/.

Future public auth (out of scope, but architecture-aware)

Public will eventually have its own auth + accounts. Not built now. Architectural slots reserved:

  • apps/public-web/lib/auth/ directory ready for a future NextAuth config.
  • Public user tables: in Phase 1 would co-locate in the internal DB if needed; in Phase 2 they belong in PUBLIC_DB_URL.
  • Public account UI lives entirely in public-web — internal admins manage public user accounts via a future Bersama → Public Users page (data sourced from public DB).
  • Cookie domain isolation: public auth cookies must scope to public host only; internal auth cookies to internal host only.

Migration plan

Phase 0  Repo setup            (~1 day)
Phase 1  Code relocation       (~2-3 days)
Phase 2  Build + deploy parity (~2 days)
Phase 3  Traffic cutover       (~1 day)
Phase 4  Hot-spot rewrites     (rolling, post-cutover)

Phase 0 — Repo setup

  • Rename ai-ahu-chatbotahu-ai-chatbot (or create new repo and pull existing into it; either way keep current git history intact).
  • git subtree add --prefix=apps/internal-agent ../ai-ahu-data-dash main
  • git subtree add --prefix=tmp/orchestrator ../ahu-chatbot-orchestrator main (Python lands in temp dir; gets translated to TS in Phase 1, then tmp/ is deleted)
  • Set up tooling: pnpm-workspace.yaml + root package.json for TS; uv workspaces + root pyproject.toml for Python.

Phase 1 — Code relocation

  • Move src/app/(public)/tanya/* + relevant provider/streams code into apps/public-web/.
  • Move src/app/(staff)/* + src/app/(staff)/admin/* into apps/internal-web/.
  • Extract shared React/TS into packages/ui + packages/streams + packages/orchestrator-types.
  • Translate tmp/orchestrator/src/orchestrator/*.pyapps/public-web/lib/orchestrator/*.ts (Plan/Execute/Judge/Compose, vLLM client, RAG/Dash tool clients, SSE encoder, policy store). Then delete tmp/orchestrator/.
  • Duplicate apps/internal-agent into apps/public-agent — same Python code, mounts public knowledge dir, loads public prompt module.
  • Knowledge files moved to infra/knowledge/{public,internal}/.

Phase 2 — Build + deploy parity

  • infra/compose.public.yaml, infra/compose.internal.yaml, infra/compose.shared.yaml (Redis, model-gateway placeholder).
  • Build images: ahu-ai-chatbot-public, ahu-ai-chatbot-internal, ahu-ai-agent-public, ahu-ai-agent-internal.
  • Smoke runs against staging URLs (chatbot-neo-next.val.id or similar) — public stack + internal stack standing side-by-side with current production.

Phase 3 — Traffic cutover

  • DNS / reverse-proxy flip on chatbot-neo.val.id from the old ahu-chatbot-dev container to the new public stack.
  • Internal users move to the internal stack URL.
  • Old ai-ahu-chatbot, ahu-chatbot-orchestrator, ai-ahu-data-dash repos archived (kept read-only for history reference).
  • Sibling project specs that reference paths inside ai-ahu-chatbot/docs/ keep working because the chatbot repo IS the new monorepo (just renamed).

Phase 4 — Hot-spot rewrites (rolling, post-cutover)

# Hot spot Why rewrite Effort Ship with refactor?
1 lib/streams/native-provider.ts DualMode detection patched on; redesign as event-pipeline 2 days post-cutover
2 Orchestrator policy store SQLite-file → shared store (probably Postgres alongside agents); enables per-tenant policy later 2 days post-cutover
3 Dash dual-model wiring output_model wiring bolted on; promote to first-class agent config 1-2 days post-cutover
4 Admin scope-switcher + audit log Brand-new for the split admin UI 3 days with refactor
5 SSE resume primitive Last-Event-ID + Redis checkpoint for reconnect 1-2 days with refactor
6 Standard gateway request headers Drop-in additions to vLLM HTTP client 0.5 day with refactor

Items 4-6 ship with the refactor (small effort, payoff in v1). Items 1-3 are done post-cutover.

Testing strategy

  • Unit/integration tests stay co-located with each app (apps/*/tests/) and run via per-app commands.
  • Cross-app integration tests in tests/e2e/ exercise the public stack and internal stack via real HTTP (no mocks of agents or vLLM — use staging vLLM endpoint).
  • Isolation tests specifically verify the boundary: a test suite called tests/isolation/ runs against the public image and asserts (a) /app/* returns 404, (b) admin routes return 404, (c) INTERNAL_DB_URL is unset, (d) internal prompts cannot be imported from the public Python image.
  • Eval harness (packages/eval/) runs both per-app smoke evals and the existing 28-case AHU eval against both agents to verify post-refactor parity.

Risks and mitigations

Risk Mitigation
Translating Python orchestrator → TS introduces behavioral drift Per-feature parity tests against the Python orchestrator before deleting it; A/B traffic during Phase 2 staging
Public-agent and internal-agent diverge over time despite shared base packages/agno-base/ carries all shared code; agents are thin entrypoints. Drift-detection on knowledge in admin UI surfaces accidental divergence.
Public DB Phase 1 reality (public reads internal DB) is forgotten and becomes permanent Documented here explicitly; tracked as a Phase 2 follow-up; PUBLIC_DB_URL env stub present from day 1 as a visible reminder
Cutover breaks chatbot-neo.val.id mid-traffic Staging URL parallel to prod; DNS TTL lowered before cutover; rollback = flip DNS/reverse proxy back
Sibling repos (ahu-doc-classifier, ahu-ocr-akta-notaris) still call old service URLs Old containers stay up read-only during Phase 3; URLs proxied to new stack until siblings cut over

Open questions

None blocking. Items deferred to implementation:

  • Exact tooling choice between Turborepo / Nx / plain pnpm-workspaces (will pick simplest that works during Phase 0).
  • Whether packages/agno-base/ becomes a published wheel or stays a workspace-only package (workspace-only is fine for now).
  • Where audit-log storage lives (initial: Postgres alongside agents; could move to dedicated audit DB later).