285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
export const meta = {
|
||
name: 'generate-proposal',
|
||
description: 'Tổng hợp tài liệu SAD thành proposal khách hàng dạng HTML/CSS: content (nội dung thương mại) → build (HTML) → review (rò rỉ nội bộ, trung thực, chất lượng HTML). Mỗi lần gọi chạy 1 stage (args.stage) có gate; "all" chạy liền kèm 1 vòng tự sửa theo review.',
|
||
whenToUse: 'Gọi từ skill sad-proposal sau khi SAD đã hoàn thiện (docs/SAD.md) và đã có docs/proposal/proposal-config.md.',
|
||
phases: [
|
||
{ title: 'Content', detail: 'proposal-writer: SAD + config → proposal-content.md' },
|
||
{ title: 'Build', detail: 'proposal-builder: content → index.html + artifact.html' },
|
||
{ title: 'Review', detail: 'proposal-reviewer: rò rỉ nội bộ, trung thực, chất lượng HTML' },
|
||
],
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tham số
|
||
// stage: content | build | review | all (mặc định: all)
|
||
// notes: ghi chú người duyệt khi chạy lại 1 stage (Revise)
|
||
// keyFacts: danh sách {fact, source} từ gate content, truyền cho stage review
|
||
// autoFix: (all) tự sửa 1 vòng theo review — mặc định true
|
||
// sadFile / sectionsDir / briefFile / configFile / outDir: đường dẫn
|
||
// ---------------------------------------------------------------------------
|
||
const a = args && typeof args === 'object' ? args : {}
|
||
const STAGES = ['content', 'build', 'review']
|
||
const stage = a.stage || 'all'
|
||
if (stage !== 'all' && !STAGES.includes(stage)) {
|
||
throw new Error(`args.stage không hợp lệ: "${stage}". Hợp lệ: all | ${STAGES.join(' | ')}`)
|
||
}
|
||
|
||
const SAD = a.sadFile || 'docs/SAD.md'
|
||
const S = a.sectionsDir || 'docs/sections'
|
||
const BRIEF = a.briefFile || 'docs/00-project-brief.md'
|
||
const CFG = a.configFile || 'docs/proposal/proposal-config.md'
|
||
const OUT = a.outDir || 'docs/proposal'
|
||
const CONTENT = `${OUT}/proposal-content.md`
|
||
const HTML = `${OUT}/index.html`
|
||
const ART = `${OUT}/artifact.html`
|
||
const autoFix = a.autoFix !== false
|
||
const notes = stage !== 'all' && a.notes
|
||
? `\n\n## Ghi chú từ người duyệt (bắt buộc xử lý trước khi làm gì khác)\n${a.notes}\n`
|
||
: ''
|
||
const run = (s) => stage === 'all' || stage === s
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schema — mọi agent trả structured output để script rẽ nhánh bằng code
|
||
// ---------------------------------------------------------------------------
|
||
const CONTENT_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
filesWritten: { type: 'array', items: { type: 'string' } },
|
||
sections: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: { id: { type: 'string' }, title: { type: 'string' }, sourceSadSections: { type: 'array', items: { type: 'string' } } },
|
||
required: ['id', 'title'],
|
||
},
|
||
},
|
||
placeholders: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: { id: { type: 'string' }, description: { type: 'string' }, section: { type: 'string' } },
|
||
required: ['description'],
|
||
},
|
||
},
|
||
excludedInternal: { type: 'array', items: { type: 'string' } },
|
||
keyFacts: {
|
||
type: 'array',
|
||
items: { type: 'object', properties: { fact: { type: 'string' }, source: { type: 'string' } }, required: ['fact', 'source'] },
|
||
},
|
||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||
summary: { type: 'string' },
|
||
},
|
||
required: ['filesWritten', 'sections', 'placeholders', 'confidence', 'summary'],
|
||
}
|
||
const BUILD_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
filesWritten: { type: 'array', items: { type: 'string' } },
|
||
sectionsRendered: { type: 'array', items: { type: 'string' } },
|
||
placeholdersCount: { type: 'number' },
|
||
mermaidBlocks: { type: 'number' },
|
||
approxSizeKB: { type: 'number' },
|
||
checks: {
|
||
type: 'object',
|
||
properties: {
|
||
standaloneDoc: { type: 'boolean' },
|
||
artifactVariant: { type: 'boolean' },
|
||
title: { type: 'boolean' },
|
||
tocAnchors: { type: 'boolean' },
|
||
themeTokens: { type: 'boolean' },
|
||
printCss: { type: 'boolean' },
|
||
responsiveTables: { type: 'boolean' },
|
||
mermaidLoaderGuarded: { type: 'boolean' },
|
||
},
|
||
},
|
||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||
summary: { type: 'string' },
|
||
},
|
||
required: ['filesWritten', 'checks', 'confidence', 'summary'],
|
||
}
|
||
const REVIEW_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
verdict: { type: 'string', enum: ['pass', 'revise'] },
|
||
leaks: {
|
||
type: 'array',
|
||
items: { type: 'object', properties: { file: { type: 'string' }, snippet: { type: 'string' }, why: { type: 'string' } }, required: ['file', 'snippet'] },
|
||
},
|
||
placeholders: { type: 'array', items: { type: 'string' } },
|
||
findings: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
target: { type: 'string', enum: ['content', 'html'] },
|
||
issue: { type: 'string' },
|
||
suggestion: { type: 'string' },
|
||
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||
},
|
||
required: ['target', 'issue', 'severity'],
|
||
},
|
||
},
|
||
factChecks: {
|
||
type: 'array',
|
||
items: { type: 'object', properties: { claim: { type: 'string' }, sadEvidence: { type: 'string' }, ok: { type: 'boolean' } }, required: ['claim', 'ok'] },
|
||
},
|
||
summary: { type: 'string' },
|
||
},
|
||
required: ['verdict', 'leaks', 'findings', 'summary'],
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
const sources =
|
||
`Nguồn: ${CFG} (thông tin thương mại — nguồn duy nhất cho giá/ngày/tên người), ` +
|
||
`${SAD} (nếu chưa có thì đọc ${BRIEF} + toàn bộ ${S}/01–09).`
|
||
|
||
const fmtFindings = (list) =>
|
||
list.map((f, i) => `${i + 1}. [${f.severity}] ${f.issue}${f.suggestion ? ` → ${f.suggestion}` : ''}`).join('\n')
|
||
|
||
function writeContent(extra) {
|
||
return agent(
|
||
`${sources}\nSoạn nội dung proposal khách hàng theo đúng cấu trúc và marker trong system prompt của bạn, ghi ra ${CONTENT}. ` +
|
||
`Tuyệt đối không đưa nội dung nội bộ (ghi chú rà soát, trạng thái duyệt, câu hỏi mở, findings, mã FR ngoài Phụ lục) vào proposal. ` +
|
||
`Không bịa giá/ngày/số liệu — dùng [[CẦN ĐIỀN: ...]] và liệt kê trong placeholders.${extra || ''}`,
|
||
{ agentType: 'proposal-writer', label: 'proposal-writer', phase: 'Content', schema: CONTENT_SCHEMA },
|
||
)
|
||
}
|
||
function buildHtml(extra) {
|
||
return agent(
|
||
`Đọc ${CONTENT} và ${CFG} (brandColor, language, logo nếu có). Dựng ${HTML} (tài liệu HTML độc lập hoàn chỉnh) ` +
|
||
`và ${ART} (biến thể Artifact: không doctype/html/head/body, bắt đầu bằng <title> rồi <style>) theo đúng design spec trong system prompt. ` +
|
||
`Không đổi câu chữ của content; mọi [[CẦN ĐIỀN]] phải thành <mark class="todo">. Tự kiểm checklist trước khi trả kết quả.${extra || ''}`,
|
||
{ agentType: 'proposal-builder', label: 'proposal-builder', phase: 'Build', schema: BUILD_SCHEMA },
|
||
)
|
||
}
|
||
function reviewAll(keyFacts) {
|
||
const facts = Array.isArray(keyFacts) && keyFacts.length
|
||
? `\nkeyFacts từ proposal-writer cần đối chiếu từng mục:\n${keyFacts.map((k) => `- ${k.fact} (nguồn: ${k.source})`).join('\n')}`
|
||
: ''
|
||
return agent(
|
||
`Rà soát ${CONTENT}, ${HTML}, ${ART} đối chiếu với ${SAD} (hoặc ${BRIEF} + ${S}/) và ${CFG}. ` +
|
||
`Ba lăng kính: (1) rò rỉ nội dung nội bộ; (2) độ trung thực số liệu/cam kết; (3) placeholder còn lại và chất lượng HTML theo checklist. ` +
|
||
`Không sửa file — chỉ trả findings.${facts}`,
|
||
{ agentType: 'proposal-reviewer', label: 'proposal-reviewer', phase: 'Review', schema: REVIEW_SCHEMA },
|
||
)
|
||
}
|
||
|
||
function contentGate(c) {
|
||
const r = c || {}
|
||
return {
|
||
stage: 'content', ok: !!c,
|
||
filesWritten: r.filesWritten || [],
|
||
sections: r.sections || [],
|
||
placeholders: r.placeholders || [],
|
||
excludedInternal: r.excludedInternal || [],
|
||
keyFacts: r.keyFacts || [],
|
||
confidence: r.confidence || 'unknown',
|
||
summary: r.summary || '',
|
||
}
|
||
}
|
||
function buildGate(b) {
|
||
const r = b || {}
|
||
const checks = r.checks || {}
|
||
const failed = Object.keys(checks).filter((k) => checks[k] === false)
|
||
return {
|
||
stage: 'build', ok: !!b,
|
||
filesWritten: r.filesWritten || [],
|
||
sectionsRendered: r.sectionsRendered || [],
|
||
placeholdersCount: typeof r.placeholdersCount === 'number' ? r.placeholdersCount : null,
|
||
mermaidBlocks: r.mermaidBlocks,
|
||
approxSizeKB: r.approxSizeKB,
|
||
checks, failedChecks: failed,
|
||
confidence: r.confidence || 'unknown',
|
||
summary: r.summary || '',
|
||
files: { html: HTML, artifact: ART },
|
||
}
|
||
}
|
||
function reviewGate(v) {
|
||
const r = v || {}
|
||
const leaks = r.leaks || []
|
||
const placeholders = r.placeholders || []
|
||
const findings = r.findings || []
|
||
return {
|
||
stage: 'review', ok: !!v,
|
||
verdict: r.verdict || 'unknown',
|
||
leaks, placeholders, findings,
|
||
factChecks: r.factChecks || [],
|
||
failedFacts: (r.factChecks || []).filter((f) => f.ok === false),
|
||
canSend: r.verdict === 'pass' && leaks.length === 0 && placeholders.length === 0,
|
||
summary: r.summary || '',
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pipeline
|
||
// ---------------------------------------------------------------------------
|
||
const report = { stage, autoFixed: false, files: { content: CONTENT, html: HTML, artifact: ART } }
|
||
if (stage === 'all') log('Chế độ "all": content → build → review, tự sửa tối đa 1 vòng theo review. Không có cổng phê duyệt giữa các bước.')
|
||
|
||
if (run('content')) {
|
||
phase('Content')
|
||
const c = await writeContent(notes)
|
||
report.content = contentGate(c)
|
||
if (stage === 'content') return report.content
|
||
}
|
||
|
||
if (run('build')) {
|
||
phase('Build')
|
||
const b = await buildHtml(notes)
|
||
report.build = buildGate(b)
|
||
if (stage === 'build') return report.build
|
||
}
|
||
|
||
if (run('review')) {
|
||
phase('Review')
|
||
let keyFacts = report.content ? report.content.keyFacts : (Array.isArray(a.keyFacts) ? a.keyFacts : [])
|
||
const v = await reviewAll(keyFacts)
|
||
report.review = reviewGate(v)
|
||
if (stage === 'review') return report.review
|
||
|
||
// --- all: 1 vòng tự sửa ---------------------------------------------------
|
||
if (autoFix && v && v.verdict === 'revise') {
|
||
const fixable = (v.findings || []).filter((f) => f.severity !== 'low')
|
||
const leakNotes = (v.leaks || []).map((l) => ({
|
||
severity: 'high', target: 'content',
|
||
issue: `Rò rỉ nội dung nội bộ trong ${l.file}: "${l.snippet}"`,
|
||
suggestion: l.why || 'Loại bỏ hoặc viết lại theo góc nhìn khách hàng',
|
||
}))
|
||
const contentNotes = fixable.filter((f) => f.target === 'content').concat(leakNotes)
|
||
const htmlNotes = fixable.filter((f) => f.target === 'html')
|
||
|
||
if (contentNotes.length) {
|
||
log(`Tự sửa: ${contentNotes.length} finding nội dung → chạy lại content, build, review.`)
|
||
phase('Content')
|
||
const c2 = await writeContent(`\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(contentNotes)}\n`)
|
||
report.content = contentGate(c2)
|
||
keyFacts = report.content.keyFacts
|
||
phase('Build')
|
||
const b2 = await buildHtml(
|
||
htmlNotes.length
|
||
? `\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(htmlNotes)}\n\nNội dung cũng đã được sửa — dựng lại từ content mới.`
|
||
: '\n\nNội dung đã được sửa theo review — dựng lại HTML từ content mới.',
|
||
)
|
||
report.build = buildGate(b2)
|
||
} else if (htmlNotes.length) {
|
||
log(`Tự sửa: ${htmlNotes.length} finding HTML → chạy lại build, review.`)
|
||
phase('Build')
|
||
const b2 = await buildHtml(`\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(htmlNotes)}\n`)
|
||
report.build = buildGate(b2)
|
||
}
|
||
|
||
if (contentNotes.length || htmlNotes.length) {
|
||
phase('Review')
|
||
const v2 = await reviewAll(keyFacts)
|
||
report.review = reviewGate(v2)
|
||
report.autoFixed = true
|
||
} else {
|
||
log('Review yêu cầu sửa nhưng chỉ có finding mức low — bỏ qua tự sửa, để người duyệt quyết định.')
|
||
}
|
||
}
|
||
}
|
||
|
||
return report
|