init git
This commit is contained in:
284
.claude/workflows/generate-proposal.js
Normal file
284
.claude/workflows/generate-proposal.js
Normal file
@@ -0,0 +1,284 @@
|
||||
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
|
||||
Reference in New Issue
Block a user