Files
AriWeb-Suite/modules/webmaker/lib/planner.js

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 };