report-contract.mjs 5.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. /** Shared by the Skill, save service and dashboard. No credentials or runtime dependencies. */
  2. export const DELIVERY_SCHEMA = 'ksf.workshop-delivery.v1';
  3. export const CORE_DELIVERABLES = Object.freeze([
  4. { key: 'market', name: '市场分析', anchor: 'market-analysis', modules: ['D1-01', 'D1-03'] },
  5. { key: 'social', name: '社媒场景需求', anchor: 'social-needs', modules: [] },
  6. { key: 'evidence', name: '用户与竞品证言池', anchor: 'evidence-pool', modules: [] },
  7. { key: 'opportunities', name: '三方机会卡', anchor: 'opportunity-cards', modules: ['D2-04'] },
  8. { key: 'validation', name: '产品论证机会清单', anchor: 'opportunity-validation', modules: ['D2-05', 'D2-06'] },
  9. { key: 'value', name: '场景买点方案', anchor: 'buying-points', modules: ['D2-07'] },
  10. { key: 'proposal', name: '产品概念提案', anchor: 'product-proposal', modules: [] },
  11. ]);
  12. export const DELIVERY_KINDS = Object.freeze({
  13. market: { category: 'category', types: ['category_report'] },
  14. social: { category: 'social', types: ['voc_report'] },
  15. evidence: { category: 'evidence', types: ['voc_report'] },
  16. opportunities: { category: 'opportunities', types: ['opportunity_card'] },
  17. validation: { category: 'assessment', types: ['assessment_report'] },
  18. value: { category: 'value', types: ['concept'] },
  19. proposal: { category: 'concept', types: ['concept'] },
  20. competitor: { category: 'competitor', types: ['competitor_report'] },
  21. pain: { category: 'pain', types: ['pain_list'] },
  22. research: { category: 'voc', types: ['voc_report'] },
  23. method: { category: 'method', types: ['category_report', 'pain_list', 'assessment_report'] },
  24. });
  25. export const FALLBACK_REPORT_TYPES = Object.freeze({
  26. category_report: 'category', competitor_report: 'competitor', voc_report: 'voc',
  27. pain_list: 'pain', opportunity_card: 'opportunities', assessment_report: 'assessment', concept: 'concept',
  28. });
  29. export const REPORT_ATTACHMENT_TYPES = Object.freeze({
  30. 'text/html': ['html', 'htm'], 'text/markdown': ['md'], 'text/plain': ['txt'],
  31. 'application/json': ['json'], 'application/pdf': ['pdf'],
  32. 'image/png': ['png'], 'image/jpeg': ['jpg', 'jpeg'], 'image/webp': ['webp'],
  33. });
  34. export const REPORT_ATTACHMENT_LIMITS = Object.freeze({ count: 8, perFileBytes: 1024 * 1024, totalBytes: 2 * 1024 * 1024 });
  35. const deliveryText = x => typeof x === 'string' && Boolean(x.trim());
  36. const deliveryChapters = report => Array.isArray(report?.chapters) ? report.chapters : [];
  37. const deliveryValue = x => typeof x === 'string' ? deliveryText(x) : typeof x === 'number' ? Number.isFinite(x)
  38. : Array.isArray(x) ? x.some(deliveryValue) : x && typeof x === 'object' ? Object.values(x).some(deliveryValue) : false;
  39. export function chapterHasContent(chapter) {
  40. return Array.isArray(chapter?.sections) && chapter.sections.some(s => {
  41. if (!s || typeof s !== 'object') return false;
  42. if (s.kind === 'para' || s.kind === 'longpara') return deliveryText(s.text);
  43. if (s.kind === 'table' || s.kind === 'matrix') return Array.isArray(s.data?.rows) && s.data.rows.some(deliveryValue);
  44. return deliveryValue(s.data);
  45. });
  46. }
  47. export function reportBodyStatus(artifact) {
  48. if (deliveryChapters(artifact?.report).some(chapterHasContent)) return 'report';
  49. return deliveryText(artifact?.rawText) ? 'text' : 'none';
  50. }
  51. export function validateReportDelivery(report, artifactType) {
  52. const d = report?.delivery;
  53. if (d === undefined) return null; // Historical clients remain readable and writable.
  54. const reject = message => { throw Object.assign(new Error(message), { code: 'VALIDATION_FAILED' }); };
  55. if (!d || typeof d !== 'object' || Array.isArray(d)
  56. || Object.keys(d).some(k => !['schema', 'moduleId', 'kind', 'contentStatus', 'outputs'].includes(k))) reject('报告交付标记字段无效');
  57. if (d.schema !== DELIVERY_SCHEMA || !Object.hasOwn(DELIVERY_KINDS, d.kind) || !DELIVERY_KINDS[d.kind].types.includes(artifactType)) reject('报告分类与成果类型不匹配');
  58. if (!/^(D1-(0[1-4]|N)|D2-0[1-8])$/.test(d.moduleId || '')) reject('报告环节标记无效');
  59. if (!['draft', 'ready'].includes(d.contentStatus) || !Array.isArray(d.outputs)
  60. || new Set(d.outputs).size !== d.outputs.length) reject('报告交付状态无效');
  61. for (const key of d.outputs) {
  62. const output = CORE_DELIVERABLES.find(o => o.key === key);
  63. if (!output || !deliveryChapters(report).some(ch => ch?.id === output.anchor && chapterHasContent(ch))) reject('交付物须对应有实质正文的稳定章节:' + key);
  64. }
  65. if (d.contentStatus === 'ready' && (!deliveryChapters(report).some(chapterHasContent)
  66. || CORE_DELIVERABLES.some(o => o.key === d.kind) && !d.outputs.includes(d.kind))) reject('已提交报告缺少对应的交付物正文');
  67. return d;
  68. }
  69. export function reportCategoryOf(artifact) {
  70. const type = artifact?.type || artifact?.artifactType;
  71. try {
  72. const d = validateReportDelivery(artifact?.report, type);
  73. if (d) return DELIVERY_KINDS[d.kind].category;
  74. } catch { /* Do not trust malformed legacy metadata; retain its artifact type. */ }
  75. // Only unambiguous, substantive legacy anchors refine a broad category.
  76. const keys = CORE_DELIVERABLES.filter(o => deliveryChapters(artifact?.report).some(ch => ch?.id === o.anchor && chapterHasContent(ch)));
  77. const compatible = keys.filter(o => DELIVERY_KINDS[o.key].types.includes(type));
  78. if (compatible.length === 1) return DELIVERY_KINDS[compatible[0].key].category;
  79. return Object.hasOwn(FALLBACK_REPORT_TYPES, type) ? FALLBACK_REPORT_TYPES[type] : '';
  80. }