POSA_Copyrighter/web/operator-gui/app-state.js
changukyu 2735ce1194 feat: 운영자 신원 기록 + 게시처 성격(언론·공식 / 무단 게시처) 표시
④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청
   범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 →
   X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이
   막힌다. 인증이 아니라 기록 — 권한은 공유 토큰.
⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에
   '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지.

verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-26 14:46:20 +09:00

168 lines
3.6 KiB
JavaScript

// Part 1 of 15 of the operator console app script.
//
// Label imports and the in-memory client state every other part reads.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
const {
riskLabels,
decisionLabels,
providerLabels,
sourceLabels,
providerStatusLabels,
candidateStatusLabels,
evidenceStatusLabels,
knowledgeStatusLabels,
provenanceLabels,
knowledgeTypeLabels,
} = window.OperatorLabels;
const retiredProviderIds = new Set();
const operatorSearchProviders = [
{ id: "naver", label: providerLabels.naver },
{ id: "google_search", label: providerLabels.google_search },
];
const submissions = [];
const providers = [];
const knowledgeEntries = [];
const searchCoverage = {};
let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = "";
let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
coverageGoodRate: 70,
coverageWarnRate: 40,
queryGoodRate: 70,
queryWarnRate: 40,
});
const coverageThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS };
const collectionCandidates = [];
const corrections = [];
const auditEvents = [];
const decisionMemoPresets = [];
const state = {
currentView: "queue",
workbenchTab: "evidence",
knowledgeTab: "collect",
currentCollectionQuery: "",
currentCollectionProvider: "",
selectedCaseId: submissions[0]?.id || "",
apiError: "",
submissionQueue: null,
intakeConflicts: [],
importStatus: {
tone: "idle",
message: "로컬 제출 저장소 변경 대기 중",
},
filters: {
risk: "all",
source: "all",
decision: "all",
sort: "risk",
query: "",
},
};
const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory
// only (never localStorage/sessionStorage): the first authenticated request makes
// the server mint an HttpOnly session cookie, which is what subsequently
// authorizes <img>/media requests that cannot carry a header.
let authToken = "";
// 판정·증거 표시를 누가 했는지 감사 로그에 남기는 운영자 이름. 인증이 아니라 기록용이다
// (권한은 공유 토큰이 정한다). 비밀이 아니므로 브라우저별로 기억해 매번 묻지 않는다.
const OPERATOR_NAME_KEY = "copyrighter.operatorName";
let operatorName = (() => {
try {
return (window.localStorage.getItem(OPERATOR_NAME_KEY) || "").trim();
} catch (_error) {
return "";
}
})();
function setOperatorName(value) {
operatorName = String(value || "").trim().slice(0, 40);
try {
window.localStorage.setItem(OPERATOR_NAME_KEY, operatorName);
} catch (_error) {
// 저장이 막힌 브라우저에서도 이번 세션 동안은 동작한다.
}
}
// 이름 없이 판정하면 감사 로그의 "누가"가 비어 버린다. 판정 경로들이 이것으로 막는다.
function requireOperatorName(showError) {
if (operatorName) return true;
showError("판정 전에 왼쪽 아래 '운영자' 칸에 이름을 입력하세요 — 감사 기록에 남습니다.");
document.getElementById("operator-name")?.focus();
return false;
}
let pendingAuthPrompt = null;