Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 24 additions & 3 deletions server/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}…에 대한 모의 응답입니다. `
Expand All @@ -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(() => ({}));
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/orchestrator.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
Loading