diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..3b3bf8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `attribution` (`service`/`account`) to the contextual-orchestrator AI + briefing call so scopeweave's LLM usage is attributed in the + orchestrator's cost ledger instead of appearing as unattributed spend. + Only orchestrator's own allowed attribution dimensions are ever forwarded + (unknown keys and empty/null values are dropped); omitted entirely when + the caller passes none, matching pre-existing behavior exactly. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/package.json b/package.json index 7790e678..a782ce9f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/orchestrator.test.mjs", "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/server/app.mjs b/server/app.mjs index 13d95e5d..0608459a 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -981,7 +981,7 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { const analysis = await orchestratorChat([ { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, { role: 'user', content: context }, - ]); + ], { service: 'scopeweave', account: p.org_id }); logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); return c.json({ analysis }); } catch (e) { diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index 1205ebe7..cf543b2c 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -6,7 +6,24 @@ const OC_TOKEN = process.env.ORCHESTRATOR_TOKEN || ''; export const orchestratorMock = !OC_URL; -export async function chat(messages) { +// orchestrator는 알 수 없는 필드를 거부(strict validation)하지만 attribution은 +// 명시적으로 허용된 필드다(ATTRIBUTION_DIMENSIONS: account/service/upstream_api/ +// model_name/team/group/company, + provider 별칭). 값을 넘기지 않으면 해당 호출은 +// orchestrator 비용 원장에서 "unattributed"로 집계된다. +const ATTRIBUTION_DIMENSIONS = new Set([ + 'account', 'service', 'upstream_api', 'model_name', 'team', 'group', 'company', 'provider', +]); + +function sanitizeAttribution(attribution) { + if (!attribution || typeof attribution !== 'object') return undefined; + const entries = Object.entries(attribution).filter( + ([key, value]) => ATTRIBUTION_DIMENSIONS.has(key) && value != null && String(value).length > 0, + ); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries.map(([key, value]) => [key, String(value)])); +} + +export async function chat(messages, attribution) { if (orchestratorMock) { const user = messages.filter((m) => m.role === 'user').map((m) => m.content).join('\n'); return `[mock-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 모의 응답입니다. ` @@ -15,14 +32,18 @@ export async function chat(messages) { const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 60000); try { + const cleanAttribution = sanitizeAttribution(attribution); const res = await fetch(`${OC_URL}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', ...(OC_TOKEN ? { authorization: `Bearer ${OC_TOKEN}` } : {}), }, - // orchestrator는 알 수 없는 필드를 거부(strict validation) — model+messages만 전송. - body: JSON.stringify({ model: 'contextual-orchestrator', messages }), + body: JSON.stringify({ + model: 'contextual-orchestrator', + messages, + ...(cleanAttribution ? { attribution: cleanAttribution } : {}), + }), signal: ctrl.signal, }); const data = await res.json().catch(() => ({})); diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs new file mode 100644 index 00000000..e4b545f9 --- /dev/null +++ b/tests/unit/orchestrator.test.mjs @@ -0,0 +1,64 @@ +// contextual-orchestrator client: attribution forwarding + sanitization. +// Run: node tests/unit/orchestrator.test.mjs +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +// orchestrator.mjs reads ORCHESTRATOR_URL at module load time, so the +// non-mock path can only be exercised in a fresh process with the env var +// already set — same pattern as tests/api/auth-secret.test.mjs. Stubs +// global fetch and prints the captured request body as JSON for this +// process to assert on. +function chatRequestBody(attribution) { + const script = ` + globalThis.fetch = async (url, opts) => { + console.log(JSON.stringify({ url, body: JSON.parse(opts.body) })); + return { ok: true, json: async () => ({ choices: [{ message: { content: 'ok' } }] }) }; + }; + const { chat } = await import('./server/orchestrator.mjs'); + await chat([{ role: 'user', content: 'hi' }], ${JSON.stringify(attribution)}); + `; + const env = { ...process.env, ORCHESTRATOR_URL: 'http://orchestrator.test', ORCHESTRATOR_TOKEN: 'tok' }; + const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', script], + { cwd: process.cwd(), env, encoding: 'utf8' }, + ); + assert.equal(result.status, 0, `chat() failed: ${result.stderr}`); + return JSON.parse(result.stdout.trim()); +} + +// Known dimensions are forwarded, coerced to strings, exactly as given. +{ + const { url, body } = chatRequestBody({ service: 'scopeweave', account: 'org-123' }); + assert.equal(url, 'http://orchestrator.test/v1/chat/completions'); + assert.equal(body.model, 'contextual-orchestrator'); + assert.deepEqual(body.attribution, { service: 'scopeweave', account: 'org-123' }); +} + +// Unknown keys and null/empty values are dropped, never forwarded verbatim. +{ + const { body } = chatRequestBody({ + service: 'scopeweave', + not_a_real_dimension: 'x', + account: '', + team: null, + }); + assert.deepEqual(body.attribution, { service: 'scopeweave' }); +} + +// If nothing valid remains after sanitizing, the field is omitted entirely +// rather than sent as an empty object — matches orchestrator's contract of +// treating a present-but-empty attribution differently from an absent one. +{ + const { body } = chatRequestBody({ not_a_real_dimension: 'x' }); + assert.equal('attribution' in body, false); +} + +// No attribution argument at all: unchanged pre-existing behavior. +{ + const { body } = chatRequestBody(undefined); + assert.equal('attribution' in body, false); + assert.deepEqual(Object.keys(body).sort(), ['messages', 'model']); +} + +console.log('✓ orchestrator attribution tests passed');