feat(advisor): Phase 1 — endpoint backward-compat (keep breakpilot-workspace working)
The advisor endpoint now serves two shapes off one orchestration:
- new FE ({question}) -> v3 JSON contract (clarity/answer/evidence/citations/...).
- legacy consumer ({message}, e.g. breakpilot-workspace which reads a text stream and
persists raw bytes) -> plain-text stream of the L2 answer (clean prose, no [n] markup,
no clarify gate). isLegacyRequest() discriminates; answerSystem() gains withCitations.
Prevents the v3 contract from breaking breakpilot-workspace's chat (CLAUDE.md rule #4,
keep every consumer working). No deploy. tsc clean, 13 vitest (incl. isLegacyRequest),
check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||||
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
|
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
|
||||||
import { completeAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
||||||
import {
|
import {
|
||||||
buildCitations,
|
buildCitations,
|
||||||
|
isLegacyRequest,
|
||||||
mapClarity,
|
mapClarity,
|
||||||
mapFootnotes,
|
mapFootnotes,
|
||||||
numberedEvidenceForPrompt,
|
numberedEvidenceForPrompt,
|
||||||
@@ -44,15 +45,24 @@ folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehn
|
|||||||
|
|
||||||
const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.`
|
const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.`
|
||||||
|
|
||||||
function answerSystem(soul: string | null, country: Country | undefined, evidenceBlock: string): string {
|
function answerSystem(
|
||||||
|
soul: string | null,
|
||||||
|
country: Country | undefined,
|
||||||
|
evidenceBlock: string,
|
||||||
|
withCitations = true,
|
||||||
|
): string {
|
||||||
let s = soul || FALLBACK_SYSTEM
|
let s = soul || FALLBACK_SYSTEM
|
||||||
if (country) s += countryBlock(country)
|
if (country) s += countryBlock(country)
|
||||||
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
|
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
|
||||||
s += `\n\n## Antwortformat (WICHTIG)
|
s += `\n\n## Antwortformat (WICHTIG)
|
||||||
- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.
|
- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.`
|
||||||
- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]).
|
if (withCitations) {
|
||||||
- Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt.
|
s += `\n- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]).
|
||||||
- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.`
|
- Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt.`
|
||||||
|
} else {
|
||||||
|
s += `\n- Nenne Fundstellen nur, wo sie der Antwort dienen (natuerlich im Text, KEIN [n]-Markup).`
|
||||||
|
}
|
||||||
|
s += `\n- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.`
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +80,28 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retrieved = await retrieveFull(question, context)
|
const retrieved = await retrieveFull(question, context)
|
||||||
|
|
||||||
|
// Backward-compat: legacy consumers (breakpilot-workspace) send {message} and read a plain-text
|
||||||
|
// stream. Serve the L2 answer streamed (clean prose, no [n]); no clarify gate, no JSON.
|
||||||
|
if (isLegacyRequest(body)) {
|
||||||
|
const legacyEvidence = retrieved.evidence ?? []
|
||||||
|
const legacySoul = await readSoulFile('compliance-advisor')
|
||||||
|
const legacyStream = await streamAdvisorAnswer([
|
||||||
|
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false) },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
])
|
||||||
|
if (!legacyStream) {
|
||||||
|
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
|
||||||
|
}
|
||||||
|
return new NextResponse(legacyStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Advisor-Format': 'legacy-stream',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const mode = resolveMode(retrieved.clarity?.mode, !!context)
|
const mode = resolveMode(retrieved.clarity?.mode, !!context)
|
||||||
|
|
||||||
if (mode === 'clarify') {
|
if (mode === 'clarify') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
mapFootnotes,
|
mapFootnotes,
|
||||||
buildCitations,
|
buildCitations,
|
||||||
numberedEvidenceForPrompt,
|
numberedEvidenceForPrompt,
|
||||||
|
isLegacyRequest,
|
||||||
} from '../advisor/retrieve-mapping'
|
} from '../advisor/retrieve-mapping'
|
||||||
import type { EvidenceUnit } from '../advisor/contract'
|
import type { EvidenceUnit } from '../advisor/contract'
|
||||||
|
|
||||||
@@ -68,3 +69,13 @@ describe('mapFootnotes', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isLegacyRequest', () => {
|
||||||
|
it('message-only (workspace) -> legacy stream', () => {
|
||||||
|
expect(isLegacyRequest({ message: 'Ist meine DSE ausreichend?' })).toBe(true)
|
||||||
|
})
|
||||||
|
it('question present -> contract (JSON)', () => {
|
||||||
|
expect(isLegacyRequest({ question: 'x', message: 'y' })).toBe(false)
|
||||||
|
expect(isLegacyRequest({ question: 'x' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -81,3 +81,11 @@ export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string {
|
|||||||
})
|
})
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compat discriminator: legacy consumers (e.g. breakpilot-workspace) send `{message}`
|
||||||
|
* and read a plain-text stream; the new FE sends `{question}` and expects the JSON contract.
|
||||||
|
*/
|
||||||
|
export function isLegacyRequest(body: { question?: unknown; message?: unknown }): boolean {
|
||||||
|
return body.question == null && typeof body.message === 'string'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user