release: v2.0.3 - AI 1-Person Company Engine & Business Intelligence
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* CEO planner — turns a user prompt into a `CompanyTaskPlan`.
|
||||
*
|
||||
* Lifecycle of one planner call:
|
||||
* 1. Build the planner system prompt (template + active-agent list).
|
||||
* 2. Hit the AI service with the user prompt as the user message.
|
||||
* 3. Parse the response through a 4-stage JSON pipeline that tolerates
|
||||
* ```json fences, leading thoughts, truncated outputs, and minor key
|
||||
* misspellings. Smaller local models violate "no extra text" rules
|
||||
* *constantly*, so a permissive parser is required.
|
||||
* 4. Normalize agent ids: accept Korean nicknames (`레오` → `youtube`,
|
||||
* `코다리` → `developer`) and filter out tasks for inactive agents.
|
||||
*
|
||||
* The function never throws — it always returns a `CompanyTaskPlan`. If
|
||||
* everything fails we surface an empty plan with a brief that explains what
|
||||
* happened, and the dispatcher treats that as "nothing to dispatch, just
|
||||
* relay the chat-style reply".
|
||||
*/
|
||||
import { IAIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { COMPANY_AGENTS } from './agents';
|
||||
import { isAgentActive } from './companyConfig';
|
||||
import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets';
|
||||
import { buildPlannerSystemPrompt } from './promptBuilder';
|
||||
import { CompanyState, CompanyTaskPlan } from './types';
|
||||
|
||||
export interface PlannerResult {
|
||||
plan: CompanyTaskPlan;
|
||||
/** True iff JSON parsing succeeded — false means we fell back to empty. */
|
||||
parsed: boolean;
|
||||
/** Raw LLM output (kept for the chat / debug log). */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: CompanyTaskPlan = { brief: '', tasks: [] };
|
||||
|
||||
/**
|
||||
* Map Korean agent nicknames + likely typos to canonical ids. Built once
|
||||
* from the static AGENTS map so it stays in sync with renames.
|
||||
*/
|
||||
const NAME_TO_ID: Record<string, string> = (() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, def] of Object.entries(COMPANY_AGENTS)) {
|
||||
out[id.toLowerCase()] = id;
|
||||
out[def.name.toLowerCase()] = id;
|
||||
// Also catch the role keyword (e.g. "designer", "writer")
|
||||
const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase();
|
||||
if (roleHead && !out[roleHead]) out[roleHead] = id;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
function _canonicalAgentId(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const key = raw.trim().toLowerCase();
|
||||
return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-stage JSON extractor — same idea as Connect_origin's planner but built
|
||||
* fresh here so we don't carry over its 21K-line file. Each stage is a fall-
|
||||
* through: we keep trying until something gives us a parseable object.
|
||||
*/
|
||||
function _parsePlanJson(raw: string): CompanyTaskPlan | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
|
||||
// Stage 1 — strip ```json … ``` fence + leading "okay let me think" prose.
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
|
||||
// Stage 2 — direct JSON.parse.
|
||||
try {
|
||||
const obj = JSON.parse(stage1);
|
||||
const plan = _coercePlan(obj);
|
||||
if (plan) return plan;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Stage 3 — find the first balanced `{ … }` and parse just that. Smaller
|
||||
// models love to prepend explanations or append trailing notes.
|
||||
const balanced = _extractFirstBalancedObject(stage1);
|
||||
if (balanced) {
|
||||
try {
|
||||
const obj = JSON.parse(balanced);
|
||||
const plan = _coercePlan(obj);
|
||||
if (plan) return plan;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// Stage 4 — regex recovery. If JSON is truncated mid-task we still try
|
||||
// to pull `brief` + any complete `{agent, task}` pairs from the text.
|
||||
const briefMatch = stage1.match(/"brief"\s*:\s*"([\s\S]*?)"/);
|
||||
const brief = briefMatch ? briefMatch[1] : '';
|
||||
const tasks: CompanyTaskPlan['tasks'] = [];
|
||||
const taskRe = /\{\s*"agent"\s*:\s*"([^"]+)"\s*,\s*"task"\s*:\s*"([\s\S]*?)"\s*\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = taskRe.exec(stage1))) {
|
||||
tasks.push({ agent: m[1].trim(), task: m[2].trim() });
|
||||
}
|
||||
if (brief || tasks.length > 0) return { brief: brief.trim(), tasks };
|
||||
return null;
|
||||
}
|
||||
|
||||
function _coercePlan(obj: unknown): CompanyTaskPlan | null {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const o = obj as Record<string, unknown>;
|
||||
const brief = typeof o.brief === 'string' ? o.brief : '';
|
||||
const rawTasks = Array.isArray(o.tasks) ? o.tasks : [];
|
||||
const tasks: CompanyTaskPlan['tasks'] = [];
|
||||
for (const t of rawTasks) {
|
||||
if (!t || typeof t !== 'object') continue;
|
||||
const tt = t as Record<string, unknown>;
|
||||
if (typeof tt.agent === 'string' && typeof tt.task === 'string') {
|
||||
tasks.push({ agent: tt.agent.trim(), task: tt.task.trim() });
|
||||
}
|
||||
}
|
||||
return { brief: brief.trim(), tasks };
|
||||
}
|
||||
|
||||
/** Find the first complete `{ … }` block respecting brace nesting. */
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + normalize a freshly-parsed plan against the current company
|
||||
* state. Tasks targeting unknown / inactive agents are dropped, and Korean
|
||||
* nicknames are rewritten to canonical ids.
|
||||
*/
|
||||
export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
|
||||
const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
|
||||
const dropped: string[] = [];
|
||||
for (const t of plan.tasks) {
|
||||
const canonical = _canonicalAgentId(t.agent);
|
||||
if (!canonical) {
|
||||
dropped.push(`unknown:${t.agent}`);
|
||||
continue;
|
||||
}
|
||||
if (canonical === 'ceo') {
|
||||
// CEO is the orchestrator — it never receives a task in `tasks`
|
||||
// (the report phase calls it separately). Drop silently.
|
||||
dropped.push('ceo:self-dispatch');
|
||||
continue;
|
||||
}
|
||||
if (!isAgentActive(state, canonical)) {
|
||||
dropped.push(`inactive:${canonical}`);
|
||||
continue;
|
||||
}
|
||||
out.tasks.push({ agent: canonical, task: t.task });
|
||||
}
|
||||
if (dropped.length > 0) {
|
||||
logInfo('ceoPlanner: dropped tasks during normalization.', { dropped });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the CEO planner end-to-end. Never throws. The caller decides what to
|
||||
* do with `{ parsed: false, plan: { tasks: [] } }` — usually we surface the
|
||||
* raw text as a casual CEO reply.
|
||||
*/
|
||||
export async function runCeoPlanner(
|
||||
ai: IAIService,
|
||||
userPrompt: string,
|
||||
state: CompanyState,
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<PlannerResult> {
|
||||
const system = buildPlannerSystemPrompt(
|
||||
applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }),
|
||||
state,
|
||||
);
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system,
|
||||
user: userPrompt,
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('ceoPlanner: AI call failed.', { error: e?.message ?? String(e) });
|
||||
return { plan: EMPTY_PLAN, parsed: false, raw: '' };
|
||||
}
|
||||
|
||||
const parsed = _parsePlanJson(raw);
|
||||
if (!parsed) {
|
||||
// No JSON found — treat as a casual chat reply. The dispatcher's
|
||||
// empty-plan branch will surface `raw` as the CEO's spoken response.
|
||||
return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw };
|
||||
}
|
||||
|
||||
const plan = normalizePlan(parsed, state);
|
||||
logInfo('ceoPlanner: parsed plan.', {
|
||||
briefChars: plan.brief.length,
|
||||
taskCount: plan.tasks.length,
|
||||
agents: plan.tasks.map((t) => t.agent),
|
||||
});
|
||||
return { plan, parsed: true, raw };
|
||||
}
|
||||
Reference in New Issue
Block a user