Files
sys-analysis-design/.claude/workflows/generate-proposal.js
Leonard-ThindPad-P50 c81f249920 init git
2026-09-08 10:26:21 +07:00

285 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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