223 lines
17 KiB
JavaScript
223 lines
17 KiB
JavaScript
'use strict';
|
|||
|
|
// AI 기획 단계: 요구 분석/이해 확인, 구조 제안, 디자인 3안, 결과 검증, Brief 선갱신 수정.
|
||
|
|
// 각 단계는 Claude 헤드리스 단발 호출로 JSON 을 받아 온다(파일 쓰기 도구 없음).
|
||
|
|
const { runModel } = require('./run');
|
||
|
|
const brief = require('./brief');
|
||
|
|
|
||
|
|
const PLAN_TOOLS = 'Read,Glob,Grep,WebFetch';
|
||
|
|
const PLAN_SYSTEM = `당신은 웹사이트 기획 컨설턴트입니다. 사용자와 대화할 수 없으므로 질문 대신 결과를 내야 합니다.
|
||
|
|
반드시 마지막 답변을 하나의 JSON 객체로만 출력합니다(설명 문장, 마크다운 코드펜스 금지). JSON 문자열 안의 한국어는 그대로 씁니다.
|
||
|
|
사실정보(전화·주소·영업시간·가격·자격·후기)는 사용자가 제공한 텍스트·첨부 파일·기존 사이트에서 확인된 것만 추출하고 출처를 표시합니다. 추측으로 만들어내지 않습니다.`;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 문자열·이스케이프를 고려해 균형 잡힌 최상위 JSON 객체 후보들을 찾는다 (v1.3.10).
|
||
|
|
* 1.3.5 까지는 "첫 { 부터 마지막 } 까지" 를 잘라 파싱해, 모델이 JSON 뒤에 설명이나 두 번째 JSON 을 붙이면
|
||
|
|
* "Unexpected non-whitespace character after JSON" 으로 실패했다(webmaker SEO 초안 오류).
|
||
|
|
* @returns {string[]} 나타난 순서대로의 {…} 조각
|
||
|
|
*/
|
||
|
|
function jsonCandidates(text) {
|
||
|
|
const out = [];
|
||
|
|
for (let i = 0; i < text.length; i++) {
|
||
|
|
if (text[i] !== '{') continue;
|
||
|
|
let depth = 0, inStr = false, esc = false, j = i;
|
||
|
|
for (; j < text.length; j++) {
|
||
|
|
const c = text[j];
|
||
|
|
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
|
||
|
|
if (c === '"') inStr = true; else if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) break; }
|
||
|
|
}
|
||
|
|
if (depth === 0 && j < text.length) { out.push(text.slice(i, j + 1)); i = j; } // 다음 후보는 이 객체 뒤에서부터
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 흔한 형식 실수 고치기: 끝 쉼표, 문자열 안의 실제 줄바꿈·탭, 스마트 따옴표로 감싼 키.
|
||
|
|
* v1.3.17 (B17): 모든 수정은 문자열 "밖" 에서만 한다. 예전에는 끝 쉼표·스마트 따옴표를 문서 전체에 정규식으로 바꿔
|
||
|
|
* "literal,]" 같은 문자열 값까지 바뀌었다.
|
||
|
|
*/
|
||
|
|
function repairJson(t) {
|
||
|
|
let out = ''; let inStr = false; let esc = false; let smart = false; // smart: 스마트 따옴표(“ ”)로 연 문자열 — 닫는 것도 스마트 따옴표로 받는다
|
||
|
|
for (let c of t) {
|
||
|
|
if (inStr) {
|
||
|
|
if (esc) { esc = false; out += c; continue; }
|
||
|
|
if (c === '\\') { esc = true; out += c; continue; }
|
||
|
|
if (c === '"' || (smart && c === '”')) { inStr = false; smart = false; out += '"'; continue; }
|
||
|
|
if (c === '\n') { out += '\\n'; continue; } if (c === '\r') continue; if (c === '\t') { out += '\\t'; continue; }
|
||
|
|
out += c; continue; // 보통 문자열 안의 스마트 따옴표는 글자 그대로
|
||
|
|
}
|
||
|
|
if (c === '“' || c === '”') { inStr = true; smart = true; out += '"'; continue; } // 문자열 밖의 스마트 따옴표 = 따옴표로 쓴 것
|
||
|
|
if (c === '"') { inStr = true; out += c; continue; }
|
||
|
|
if (c === '}' || c === ']') out = out.replace(/,\s*$/, ''); // 끝 쉼표 (문자열 밖)
|
||
|
|
out += c;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* AI 응답에서 JSON 객체 하나를 꺼낸다.
|
||
|
|
* 순서: 코드펜스 안 → 균형 잡힌 후보 중 파싱되는 가장 큰 것 → 고친 뒤 다시 시도. 모두 실패하면 원문 일부를 오류에 담는다.
|
||
|
|
*/
|
||
|
|
function extractJson(text) {
|
||
|
|
if (!text) throw new Error('AI 응답이 비어 있습니다');
|
||
|
|
const pools = []; const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]); pools.push(...fences, text);
|
||
|
|
let firstErr = null;
|
||
|
|
for (const src of pools) {
|
||
|
|
const cands = jsonCandidates(src).sort((a, b) => b.length - a.length); // 큰 것(본문 JSON) 먼저 — 설명 속 작은 {…} 보다 우선
|
||
|
|
for (const c of cands) { for (const t of [c, repairJson(c)]) { try { const v = JSON.parse(t); if (v && typeof v === 'object') return v; } catch (e) { if (!firstErr) firstErr = e; } } }
|
||
|
|
}
|
||
|
|
const err = new Error('AI 응답 JSON 파싱 실패: ' + (firstErr ? firstErr.message : 'JSON 객체를 찾지 못했습니다'));
|
||
|
|
err.rawText = String(text).slice(0, 20000); throw err;
|
||
|
|
}
|
||
|
|
/** 두 실행 결과의 사용량·비용 합치기 (JSON 고침 재요청 비용까지 청구에 넣기 위해) */
|
||
|
|
function mergeResults(a, b) {
|
||
|
|
const u = { ...a.usage }; for (const [k, v] of Object.entries(b.usage || {})) if (typeof v === 'number') u[k] = (u[k] || 0) + v;
|
||
|
|
return { ...a, usage: u, costUsd: (a.costUsd || 0) + (b.costUsd || 0), numTurns: (a.numTurns || 0) + (b.numTurns || 0), durationMs: (a.durationMs || 0) + (b.durationMs || 0) };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runJson({ siteDir, prompt, onEvent, onStart, ref, userId, toolset = 'read' }) { // toolset 'none': 파일을 읽지 않는 짧은 호출 (v1.3.10 다국어 번역)
|
||
|
|
const r = await runModel({ ref, userId, siteDir, prompt, systemPrompt: PLAN_SYSTEM, toolset, noPersist: true, onEvent, onStart });
|
||
|
|
if (!r.ok) throw Object.assign(new Error(`AI 호출 실패 (${r.subtype})`), { result: r });
|
||
|
|
try { return { data: extractJson(r.resultText), result: r }; }
|
||
|
|
catch (e) {
|
||
|
|
// v1.3.10: 그래도 못 읽으면 같은 모델에 "이 텍스트를 올바른 JSON 하나로만" 고쳐 달라고 한 번만 다시 요청한다
|
||
|
|
// (파일을 다시 읽지 않는 짧은 호출이라 비용이 작다). 그 비용도 이번 작업 청구에 합친다.
|
||
|
|
// v1.3.17 (B18): 고침 호출이 실패·취소되면 그 상태(cancelled 등)와 사용량을 그대로 넘긴다 — 첫 호출 결과로 덮어 성공처럼 보이거나
|
||
|
|
// 고침 호출 비용이 청구에서 빠지던 문제. 고침 호출이 ok 가 아니면 그 응답의 JSON 은 쓰지 않는다.
|
||
|
|
const fixPrompt = `아래 텍스트는 JSON 하나여야 하는데 형식이 깨졌습니다. 내용은 바꾸지 말고 올바른 JSON 객체 하나로만 다시 출력하세요(설명·코드펜스 금지). 중간에 잘렸으면 마지막 항목을 빼고 닫으세요.\n\n${String(r.resultText || '').slice(0, 60000)}`;
|
||
|
|
let r2;
|
||
|
|
try { r2 = await runModel({ ref, userId, siteDir, prompt: fixPrompt, systemPrompt: PLAN_SYSTEM, toolset: 'none', noPersist: true, onEvent, onStart }); }
|
||
|
|
catch (e2) {
|
||
|
|
// 취소·실행 오류: 그 오류를 그대로 던지되, 첫 호출 사용량을 함께 담아 청구가 빠지지 않게 한다
|
||
|
|
if (e2 && e2.usage && r.usage) { const u = { ...r.usage }; for (const [k, v] of Object.entries(e2.usage)) if (typeof v === 'number') u[k] = (u[k] || 0) + v; e2.usage = u; } else if (e2 && !e2.usage) e2.usage = r.usage;
|
||
|
|
throw Object.assign(e2, { result: e2.result ? mergeResults(r, e2.result) : r, rawText: e.rawText });
|
||
|
|
}
|
||
|
|
const merged = mergeResults(r, r2 || {}); // 성공 여부와 관계없이 사용량·비용은 합친다
|
||
|
|
if (!r2 || !r2.ok) throw Object.assign(new Error(`AI 응답 JSON 고침 실패 (${(r2 && r2.subtype) || 'error'})`), { result: merged, rawText: e.rawText });
|
||
|
|
try { return { data: extractJson(r2.resultText), result: merged, repaired: true }; } catch (e3) { throw Object.assign(e, { result: merged }); }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function refsText(refs, fetched) {
|
||
|
|
if (!refs || !refs.length) return '';
|
||
|
|
const byUrl = new Map((fetched || []).map((f) => [f.url, f]));
|
||
|
|
const lines = refs.map((r, i) => { const f = byUrl.get(r.url); return `${i + 1}. ${r.url}${r.notes ? ` — 참고/반영할 점: ${r.notes}` : ''}${f ? (f.ok ? ` → 본문 텍스트가 ${f.file} 에 저장되어 있음 (Read 로 확인)` : ` → 가져오기 실패(${f.error}); 사용자가 적은 참고 포인트만 반영`) : ''}`; });
|
||
|
|
return '[참고 사이트]\n' + lines.join('\n') + '\n저장된 텍스트 파일을 Read 로 읽어 구성·메뉴·문구 스타일을 파악하고, 사용자가 적은 참고 포인트를 반영 방안에 포함하세요. WebFetch 는 저장 파일이 없거나 부족할 때만 보조로 사용하고, 실패하면 그냥 넘어갑니다.\n';
|
||
|
|
}
|
||
|
|
function attachText(atts) {
|
||
|
|
if (!atts || !atts.length) return '';
|
||
|
|
return '[첨부 파일] (Read 로 내용 확인)\n' + atts.map((a) => `- ${a.rel} (${a.kind}${a.textSidecar ? `, 텍스트 추출본 ${a.textSidecar}` : ''})`).join('\n') + '\n';
|
||
|
|
}
|
||
|
|
const CAT = brief.catalog();
|
||
|
|
const industryList = CAT.INDUSTRIES.map((i) => `${i.major}: ${i.minors.join(', ')}`).join('\n');
|
||
|
|
|
||
|
|
/** 1~2단계: 요구 분석 → 이해 확인 카드 */
|
||
|
|
/** 사용자가 시작 화면에서 고른 디자인 컨셉(주 색상·톤·방향) 을 프롬프트용 문장으로 */
|
||
|
|
function designText(inp) {
|
||
|
|
const d = (inp && inp.design) || {}; const parts = [];
|
||
|
|
if (d.color) parts.push(`주 색상 ${d.color}`); if (d.tone) parts.push(`톤 ${d.tone}`); if (d.direction) parts.push(`방향: ${d.direction}`);
|
||
|
|
return parts.length ? parts.join(' · ') : '(없음 — 업종과 고객에 맞게 추천)';
|
||
|
|
}
|
||
|
|
function analyzePrompt(site) {
|
||
|
|
const inp = site.plan.input;
|
||
|
|
return `다음 요구를 분석해 웹사이트 기획의 "이해 확인 카드"를 JSON 으로 작성하세요.
|
||
|
|
|
||
|
|
[사용자 요구(자유 서술)]
|
||
|
|
${inp.text || '(없음)'}
|
||
|
|
|
||
|
|
[디자인 요구 (사용자 입력)]
|
||
|
|
${designText(inp)}
|
||
|
|
[기존 사이트 URL]
|
||
|
|
${inp.existingUrl || '(없음)'}${inp.existingUrl ? (() => { const f = (site.plan.refsFetched || []).find((x) => x.existing); return f && f.ok ? ` — 본문 텍스트가 ${f.file} 에 저장되어 있음. Read 로 읽어 사업 정보와 사실정보(출처 url:${inp.existingUrl})를 추출` : ' — 가져오기 실패. 사용자 서술만 근거로 함'; })() : ''}
|
||
|
|
|
||
|
|
${attachText(site.attachments)}${refsText(inp.references, site.plan.refsFetched)}${inp.answers && inp.answers.length ? '[추가 답변]\n' + inp.answers.map((a) => `Q: ${a.q}\nA: ${a.a}`).join('\n') + '\n' : ''}
|
||
|
|
[업종 분류표] (major 는 아래 대분류 문자열 중 하나, minor 는 그 소분류 중 하나)
|
||
|
|
${industryList}
|
||
|
|
|
||
|
|
[규제 업종] medical(병원·의원·치과·한의원 등), legal(변호사·세무·법무 등), finance(금융·대부). 해당하면 regulated.category 에 표기.
|
||
|
|
|
||
|
|
출력 JSON 형식:
|
||
|
|
{
|
||
|
|
"business": {"name": "", "industry": {"major": "", "minor": ""}, "region": {"sido": "", "sigungu": ""}, "description": "한 줄 사업 요약"},
|
||
|
|
"customers": {"types": ["..."], "ages": ["20대","30대"]},
|
||
|
|
"goals": ["inquiry|booking|intro|portfolio|news|sales" 중 복수],
|
||
|
|
"features": {"contactForm": bool, "booking": bool, "board": bool, "gallery": bool, "map": bool, "reviews": bool, "faq": bool},
|
||
|
|
"recommendedStyle": {"tone": "minimal|modern|warm|luxury|vivid", "reason": ""},
|
||
|
|
"regulated": {"flag": bool, "category": "medical|legal|finance|null"},
|
||
|
|
"facts": {"phone": {"value": "", "source": "user_text|attachment:<파일명>|url:<주소>|null", "status": "provided|missing"}, "address": {...}, "hours": {...}, "prices": {...}, "credentials": {...}, "reviews": {...}},
|
||
|
|
"origin": {"business.name": "user|ai", "business.industry": "user|ai", "goals": "user|ai", "features": "user|ai", "customers": "user|ai", "recommendedStyle": "ai"},
|
||
|
|
"referenceInsights": [{"url": "", "adopt": "이 사이트에서 반영할 점 요약"}],
|
||
|
|
"questions": ["업종이나 목표가 불명확할 때만 1~2개, 아니면 빈 배열"]
|
||
|
|
}
|
||
|
|
origin 은 사용자가 명시한 값이면 "user", 추론이면 "ai" 로 표시합니다. 사실정보가 확인되지 않으면 value 를 비우고 status 를 "missing" 으로 둡니다.`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 3단계: 구조 제안 */
|
||
|
|
/** 사용자가 고른 페이지 수(plan.input.pageCount, 0=자동) → 구조 제안 규칙 문장 */
|
||
|
|
function pageRule(site) {
|
||
|
|
const n = parseInt((site.plan.input || {}).pageCount, 10) || 0;
|
||
|
|
if (n === 1) return '페이지는 정확히 1개(원페이지 사이트): 모든 내용을 home 한 페이지의 섹션으로 구성하고 내비게이션은 섹션 앵커(#id)로.';
|
||
|
|
if (n > 1) return `페이지는 정확히 ${n}개(home 포함).`;
|
||
|
|
return '페이지 3~6개.';
|
||
|
|
}
|
||
|
|
function structurePrompt(site) {
|
||
|
|
const b = site.plan.brief;
|
||
|
|
return `아래 Brief(부분)를 바탕으로 웹사이트 페이지 트리와 섹션 구성을 제안하세요.
|
||
|
|
|
||
|
|
[Brief]
|
||
|
|
${JSON.stringify({ business: b.business, goals: b.goals, features: b.features, customers: b.customers, regulated: b.regulated, facts: Object.fromEntries(Object.entries(b.facts).map(([k, v]) => [k, v.status])) }, null, 1)}
|
||
|
|
|
||
|
|
${refsText(site.plan.input.references, site.plan.refsFetched)}
|
||
|
|
[섹션 ID 목록] ${Object.entries(CAT.SECTIONS).map(([k, v]) => `${k}(${v.label}${v.feature ? `, 기능:${v.feature}` : ''})`).join(', ')}
|
||
|
|
[목표 ID] ${CAT.GOALS.map((g) => `${g.id}(${g.label})`).join(', ')}
|
||
|
|
|
||
|
|
규칙: 켜진 기능(features=true)은 반드시 어떤 페이지의 섹션에 배치. 꺼진 기능의 섹션은 넣지 않음. 규제 업종 제약(예: 의료 후기 금지) 준수. 첫 페이지는 id "home". ${pageRule(site)} 페이지당 섹션 3~7개.
|
||
|
|
출력 JSON:
|
||
|
|
{"pages": [{"id": "home", "title": "홈", "purpose": "이 페이지의 역할", "sections": [{"id": "hero", "title": "섹션 제목", "goal": "inquiry|booking|intro|portfolio|news|sales|null", "feature": "기능ID|null", "note": "무엇을 담는지 한 줄"}]}], "rationale": "구조 선택 이유 2~3문장"}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 4단계: 디자인 3안 */
|
||
|
|
function designPrompt(site) {
|
||
|
|
const b = site.plan.brief;
|
||
|
|
return `아래 Brief 로 만들 웹사이트의 디자인 방향 3안을 제안하세요. 2안 이상은 서로 뚜렷이 달라야 하며, 첫 번째 안은 추천 톤(${b.design.recommendedTone || '없음'})을 따릅니다.
|
||
|
|
[사용자 디자인 요구] ${designText(site.plan.input)} — 요구가 있으면 첫 번째 안(추천안)은 반드시 이 색상·톤·방향을 반영하고 palette.colors 의 포인트 색을 주 색상으로 씁니다.
|
||
|
|
|
||
|
|
[Brief 요약] ${JSON.stringify({ business: b.business, goals: b.goals, customers: b.customers, recommendedTone: b.design.recommendedTone, pages: b.pages.map((p) => p.id) })}
|
||
|
|
${refsText(site.plan.input.references, site.plan.refsFetched)}
|
||
|
|
[톤 ID] ${CAT.TONES.map((t) => `${t.id}(${t.label}: ${t.desc})`).join(' / ')}
|
||
|
|
[폰트 세트 ID] ${CAT.FONT_SETS.map((f) => `${f.id}(${f.label})`).join(' / ')}
|
||
|
|
|
||
|
|
출력 JSON:
|
||
|
|
{"options": [{"id": "A", "name": "안 이름", "tone": "톤ID", "palette": {"name": "", "colors": ["#텍스트", "#보조", "#배경", "#포인트"]}, "fonts": "폰트세트ID", "density": "wide|standard|dense", "imagery": "photo|illustration", "summary": "한두 문장", "heroIdea": "히어로 구성 아이디어"}, {...}, {...}], "recommended": "A"}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 7/11단계: 결과 ↔ Brief 대조 (AI) */
|
||
|
|
function verifyPrompt(site) {
|
||
|
|
const b = site.plan.brief;
|
||
|
|
const ml = site.langs && site.langs.list && site.langs.list.length > 1 ? `다국어 사이트입니다 — 기본 언어 폴더 ${site.langs.default}/ 의 파일만 검사하세요(다른 언어 폴더는 서비스가 번역해 만든 사본입니다).\n` : ''; // v1.3.10
|
||
|
|
return `현재 디렉터리의 생성된 웹사이트 파일(Read/Glob 으로 확인)을 아래 Brief 와 대조하세요.
|
||
|
|
${ml}
|
||
|
|
[Brief]
|
||
|
|
${JSON.stringify(b, null, 1)}
|
||
|
|
|
||
|
|
검사 항목: (1) 목표별 CTA 존재 (2) 켜진 기능별 섹션 존재 (3) 페이지별 파일 존재와 섹션 반영 (4) 디자인(톤·팔레트·폰트·밀도) 반영 (5) 어조·언어 준수 (6) Brief 에 없는 사실정보(전화·주소·가격·후기)가 만들어졌는지 (7) 규제 제약 위반 표현.
|
||
|
|
출력 JSON:
|
||
|
|
{"items": [{"category": "cta|feature|page|style|copy|facts|regulation", "name": "", "status": "pass|fail|partial", "detail": "근거(파일명 포함)"}], "score": 0~100, "fixes": ["실패 항목을 고치기 위한 구체 지시 문장"]}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 8/12단계: 채팅 수정 — Brief 선갱신 후 해당 부분만 재생성 (파일 도구 사용, 최종 답변에 <brief> JSON 포함) */
|
||
|
|
function editPrompt(site, instruction, attachments) {
|
||
|
|
const b = site.plan.brief;
|
||
|
|
const history = require('./prompt').historySection(site);
|
||
|
|
return `현재 디렉터리의 웹사이트를 아래 지시에 따라 수정합니다. 순서: (1) 지시가 Brief 의 어떤 항목을 바꾸는지 판단해 Brief 를 먼저 갱신 (2) 갱신된 Brief 에 맞춰 영향을 받는 파일/섹션만 수정 (3) 영향 없는 부분은 건드리지 않음.
|
||
|
|
|
||
|
|
${history}
|
||
|
|
[수정 지시]
|
||
|
|
${instruction}
|
||
|
|
|
||
|
|
[현재 Brief]
|
||
|
|
${JSON.stringify(b, null, 1)}
|
||
|
|
${attachments}
|
||
|
|
규칙: 사실정보는 Brief 값만 사용(지시에 새 사실정보가 있으면 Brief.facts 에 source:"user_text", status:"provided" 로 반영 후 사용). 없으면 "[입력 필요]". 가짜 후기 금지. 규제 제약 준수.
|
||
|
|
작업을 마친 뒤 최종 답변은 다음 형식으로만: 첫 줄부터 3줄 이내 한국어 요약, 그 다음 줄에 <brief>갱신된 Brief 전체 JSON</brief>. Brief 가 바뀌지 않았어도 전체를 다시 출력합니다.`;
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { jsonCandidates, repairJson, mergeResults, runJson, analyzePrompt, structurePrompt, designPrompt, verifyPrompt, editPrompt, extractJson };
|