pasternake commited on
Commit
931cd2b
·
verified ·
1 Parent(s): 0c8b73b

Post Audit MVP — Gradio Space (hybrid rules + Gemma 4 E4B on Modal)

Browse files
Files changed (11) hide show
  1. README.md +15 -6
  2. app.py +94 -0
  3. audit_client.py +205 -0
  4. examples.py +30 -0
  5. merge.py +104 -0
  6. platform_config.py +51 -0
  7. prompts.py +128 -0
  8. render.py +260 -0
  9. requirements.txt +6 -0
  10. rules.py +282 -0
  11. static/viewer.html +321 -0
README.md CHANGED
@@ -1,13 +1,22 @@
1
  ---
2
  title: Post Audit
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Post Audit
3
+ emoji: 📋
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "5.12.0"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: Audit posts against goal & audience (Gemma 4 + rules)
12
  ---
13
 
14
+ # Post Audit
15
+
16
+ Brief-aware social post audit for the [build-small-hackathon](https://huggingface.co/build-small-hackathon) Backyard AI track.
17
+
18
+ **Model:** Gemma 4 E4B (4.5B effective parameters, ≤32B hackathon limit)
19
+ **Inference:** Modal GPU endpoint
20
+ **Host:** Rule linters + deterministic score recomputation
21
+
22
+ Set Space secret `MODAL_AUDIT_URL` to your deployed Modal web endpoint base URL (without trailing `/audit`).
app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post Audit — Gradio Space entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gradio as gr
6
+
7
+ from audit_client import backend_label, call_modal_audit
8
+ from examples import EXAMPLE_CHAT_DUMP, EXAMPLE_WEBINAR
9
+ from merge import merge_audit, viewer_payload
10
+ from render import render_report_html
11
+ from rules import run_rules
12
+
13
+
14
+ def run_audit(platform: str, goal: str, audience: str, post: str):
15
+ if not post.strip():
16
+ return "<p>Enter a post to audit.</p>", {}
17
+
18
+ rule_warnings = run_rules(platform, goal, audience, post)
19
+ try:
20
+ llm_payload = call_modal_audit(platform, goal, audience, post)
21
+ except Exception as exc:
22
+ err = f"<p style='color:#b3261e'>Audit failed: {exc}</p>"
23
+ return err, {}
24
+
25
+ merged = merge_audit(llm_payload, rule_warnings)
26
+ view = viewer_payload(merged)
27
+ report_html = render_report_html(view)
28
+
29
+ status = (
30
+ f"<p><strong>Done.</strong> Inference: {backend_label()}. "
31
+ f"Rule warnings: {len(rule_warnings)}.</p>"
32
+ )
33
+
34
+ return status + report_html, merged
35
+
36
+
37
+ def load_example(example: dict):
38
+ return example["platform"], example["goal"], example["audience"], example["post"]
39
+
40
+
41
+ with gr.Blocks(title="Post Audit", theme=gr.themes.Soft()) as demo:
42
+ gr.Markdown(
43
+ """
44
+ # Post Audit
45
+ Audit a social post against your **goal** and **audience** before you publish.
46
+
47
+ Hybrid pipeline: deterministic rule linters + **Gemma 4 E4B** (4.5B effective) on Modal.
48
+ First run may take ~30s while the GPU container starts.
49
+ """
50
+ )
51
+
52
+ with gr.Row():
53
+ platform = gr.Dropdown(
54
+ choices=["Telegram", "LinkedIn", "X/Twitter", "Other"],
55
+ value="Telegram",
56
+ label="Platform",
57
+ )
58
+ goal = gr.Textbox(label="Goal", placeholder="Register attendees for Thursday 7pm webinar")
59
+ audience = gr.Textbox(
60
+ label="Audience",
61
+ placeholder="Product managers who own product metrics",
62
+ )
63
+ post = gr.Textbox(
64
+ label="Post draft",
65
+ lines=10,
66
+ placeholder="Paste your post here…",
67
+ )
68
+
69
+ with gr.Row():
70
+ audit_btn = gr.Button("Run audit", variant="primary")
71
+ ex_webinar = gr.Button("Load example: weak webinar CTA")
72
+ ex_chat = gr.Button("Load example: chat dump")
73
+
74
+ status_report = gr.HTML()
75
+
76
+ with gr.Accordion("Raw JSON", open=False):
77
+ raw_json = gr.JSON(label="Pipeline output")
78
+
79
+ audit_btn.click(
80
+ fn=run_audit,
81
+ inputs=[platform, goal, audience, post],
82
+ outputs=[status_report, raw_json],
83
+ )
84
+ ex_webinar.click(
85
+ fn=lambda: load_example(EXAMPLE_WEBINAR),
86
+ outputs=[platform, goal, audience, post],
87
+ )
88
+ ex_chat.click(
89
+ fn=lambda: load_example(EXAMPLE_CHAT_DUMP),
90
+ outputs=[platform, goal, audience, post],
91
+ )
92
+
93
+ if __name__ == "__main__":
94
+ demo.launch()
audit_client.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP client for Modal audit endpoint with mock fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from merge import parse_llm_json
12
+ from prompts import FEW_SHOT_ASSISTANT, build_messages
13
+
14
+
15
+ def get_modal_url() -> str | None:
16
+ return os.environ.get("MODAL_AUDIT_URL") or os.environ.get("MODAL_AUDIT_ENDPOINT")
17
+
18
+
19
+ def get_modal_timeout() -> float:
20
+ # Warm latency is ~50s, but a cold container must download + load the ~16 GB
21
+ # model first, which pushes the first request past 2 min. Generous default.
22
+ return float(os.environ.get("MODAL_AUDIT_TIMEOUT", "300"))
23
+
24
+
25
+ def get_ollama_model() -> str | None:
26
+ """Local Ollama model tag (e.g. 'gemma3:4b'); enables the local-LLM path."""
27
+ return os.environ.get("OLLAMA_MODEL")
28
+
29
+
30
+ def get_ollama_url() -> str:
31
+ return os.environ.get("OLLAMA_URL", "http://localhost:11434")
32
+
33
+
34
+ def get_ollama_timeout() -> float:
35
+ # Local CPU/Metal inference is slow: a full audit is ~2.5 min on an M1 with
36
+ # gemma3:4b, and the first (cold) call adds model-load time. Generous default.
37
+ return float(os.environ.get("OLLAMA_TIMEOUT", "300"))
38
+
39
+
40
+ def backend_label() -> str:
41
+ """Human-readable description of the inference backend run() will use."""
42
+ if get_modal_url():
43
+ return "live Gemma 4 E4B on Modal"
44
+ model = get_ollama_model()
45
+ if model:
46
+ return f"local Ollama ({model})"
47
+ return "mock LLM (set MODAL_AUDIT_URL or OLLAMA_MODEL)"
48
+
49
+
50
+ def call_modal_audit(
51
+ platform: str,
52
+ goal: str,
53
+ audience: str,
54
+ post: str,
55
+ *,
56
+ timeout: float | None = None,
57
+ ) -> dict[str, Any]:
58
+ """Dispatch to a backend: Modal endpoint, local Ollama, or deterministic mock."""
59
+ if timeout is None:
60
+ timeout = get_modal_timeout()
61
+ url = get_modal_url()
62
+ if not url:
63
+ if get_ollama_model():
64
+ return _call_ollama(platform, goal, audience, post)
65
+ return _mock_llm_response(platform, goal, audience, post)
66
+
67
+ payload = json.dumps(
68
+ {
69
+ "platform": platform,
70
+ "goal": goal,
71
+ "audience": audience,
72
+ "post": post,
73
+ }
74
+ ).encode("utf-8")
75
+ # Modal serves a single fastapi_endpoint at the root of its URL — no path suffix.
76
+ headers = {"Content-Type": "application/json"}
77
+ token = os.environ.get("MODAL_AUDIT_TOKEN")
78
+ if token:
79
+ headers["X-Audit-Token"] = token
80
+ req = urllib.request.Request(
81
+ url.rstrip("/"),
82
+ data=payload,
83
+ headers=headers,
84
+ method="POST",
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
88
+ data = json.loads(resp.read().decode("utf-8"))
89
+ except urllib.error.HTTPError as exc:
90
+ body = exc.read().decode("utf-8", errors="replace")
91
+ raise RuntimeError(f"Modal HTTP {exc.code}: {body}") from exc
92
+
93
+ if "raw" in data:
94
+ return parse_llm_json(data["raw"])
95
+ return data
96
+
97
+
98
+ def _ollama_chat(model: str, messages: list[dict[str, str]], timeout: float) -> str:
99
+ """POST messages to Ollama's /api/chat with JSON-constrained output; return content."""
100
+ body = json.dumps(
101
+ {
102
+ "model": model,
103
+ "messages": messages,
104
+ "stream": False,
105
+ "format": "json", # constrain output to valid JSON
106
+ "options": {"temperature": 0, "num_predict": 2048},
107
+ }
108
+ ).encode("utf-8")
109
+ req = urllib.request.Request(
110
+ get_ollama_url().rstrip("/") + "/api/chat",
111
+ data=body,
112
+ headers={"Content-Type": "application/json"},
113
+ method="POST",
114
+ )
115
+ try:
116
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
117
+ data = json.loads(resp.read().decode("utf-8"))
118
+ except urllib.error.HTTPError as exc:
119
+ detail = exc.read().decode("utf-8", errors="replace")
120
+ raise RuntimeError(f"Ollama HTTP {exc.code}: {detail}") from exc
121
+ except urllib.error.URLError as exc:
122
+ raise RuntimeError(
123
+ f"Cannot reach Ollama at {get_ollama_url()} — is it running? ({exc.reason})"
124
+ ) from exc
125
+ return data.get("message", {}).get("content", "")
126
+
127
+
128
+ def _call_ollama(
129
+ platform: str,
130
+ goal: str,
131
+ audience: str,
132
+ post: str,
133
+ *,
134
+ timeout: float | None = None,
135
+ ) -> dict[str, Any]:
136
+ """Run the audit against a local Ollama model. First call may be slow (model load)."""
137
+ if timeout is None:
138
+ timeout = get_ollama_timeout()
139
+ model = get_ollama_model()
140
+ messages = build_messages(platform, goal, audience, post)
141
+ raw = _ollama_chat(model, messages, timeout)
142
+ try:
143
+ return parse_llm_json(raw)
144
+ except (json.JSONDecodeError, ValueError):
145
+ # One retry with an explicit instruction, mirroring the Modal path.
146
+ retry = messages + [
147
+ {"role": "user", "content": "Return ONLY valid JSON matching the schema. No other text."}
148
+ ]
149
+ return parse_llm_json(_ollama_chat(model, retry, timeout))
150
+
151
+
152
+ def _mock_llm_response(
153
+ platform: str,
154
+ goal: str,
155
+ audience: str,
156
+ post: str,
157
+ ) -> dict[str, Any]:
158
+ """Deterministic mock when Modal URL is unset — uses few-shot shape for webinar-like posts."""
159
+ del platform, goal, audience
160
+ lower = post.lower()
161
+ if "link in bio" in lower or "webinar" in lower:
162
+ return json.loads(FEW_SHOT_ASSISTANT)
163
+ return {
164
+ "briefCheck": {
165
+ "status": "ok",
166
+ "inferred": {
167
+ "goal": "Unclear from post — needs editor review",
168
+ "audience": "In-context colleagues",
169
+ },
170
+ "gaps": [],
171
+ },
172
+ "auditReport": {
173
+ "goalAlignment": {
174
+ "overall": 40,
175
+ "cappedBy": [],
176
+ "dimensions": [
177
+ {"key": "hook", "score": 2, "rationale": "Opening lacks a clear stake or benefit."},
178
+ {"key": "clarity", "score": 2, "rationale": "Multiple topics mixed in one dump."},
179
+ {"key": "audienceFit", "score": 3, "rationale": "Jargon may fit insiders but structure is rough."},
180
+ {"key": "goalService", "score": 2, "rationale": "Does not clearly drive the stated goal actions."},
181
+ {"key": "cta", "score": 2, "rationale": "Call to action is logistics-only or missing deadline."},
182
+ ],
183
+ "summary": "Mock audit (set MODAL_AUDIT_URL for live Gemma 4 E4B). Post needs structure and a clearer CTA.",
184
+ },
185
+ "warnings": [
186
+ {
187
+ "code": "MIXED_MESSAGES",
188
+ "severity": "warning",
189
+ "source": "llm",
190
+ "message": "Artifact, task, and logistics appear mixed.",
191
+ },
192
+ {
193
+ "code": "NO_CLEAR_CTA",
194
+ "severity": "warning",
195
+ "source": "llm",
196
+ "message": "No explicit personal action with a deadline.",
197
+ },
198
+ ],
199
+ "rewriteHints": [
200
+ "Lead with why the reader should act now.",
201
+ "Separate artifact, task, and logistics into sections.",
202
+ "Add one explicit CTA with a deadline.",
203
+ ],
204
+ },
205
+ }
examples.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Demo inputs for the Gradio Space."""
2
+
3
+ EXAMPLE_WEBINAR = {
4
+ "platform": "Telegram",
5
+ "goal": "Register attendees for Thursday 7pm webinar",
6
+ "audience": "Product managers who own product metrics",
7
+ "post": (
8
+ "Webinar on product metrics 🚀🚀 Link in bio!!! "
9
+ "#product #metrics #growth #pm #webinar #training #analytics"
10
+ ),
11
+ }
12
+
13
+ EXAMPLE_CHAT_DUMP = {
14
+ "platform": "Telegram",
15
+ "goal": (
16
+ "Activate participants on 3 actions: read materials, write their own "
17
+ "conduit version, refine the schema"
18
+ ),
19
+ "audience": "Working group on decision methodology — colleagues in context",
20
+ "post": """Handbook (https://example.com/handbook) with checklists
21
+
22
+ [6/5/26 5:10 PM] Pavel Trubin:
23
+ Conduit zero-version:
24
+ - fragment one with typos and missing context
25
+ - another fragment referencing ontology without link
26
+
27
+ Please improve this conduit and turn it into a schema. Substantive thoughts go on the miro board.
28
+
29
+ Any and all important decisions will be made one way or another — complexity sits on both sides of the moment.""",
30
+ }
merge.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merge LLM output with rule warnings and recompute scores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ DIMENSION_KEYS = ("hook", "clarity", "audienceFit", "goalService", "cta")
10
+
11
+
12
+ def parse_llm_json(raw: str) -> dict[str, Any]:
13
+ text = raw.strip()
14
+ if text.startswith("```"):
15
+ text = text.split("\n", 1)[-1]
16
+ if text.endswith("```"):
17
+ text = text[:-3]
18
+ text = text.strip()
19
+ start = text.find("{")
20
+ end = text.rfind("}")
21
+ if start == -1 or end == -1:
22
+ raise ValueError("No JSON object found in model output")
23
+ return json.loads(text[start : end + 1])
24
+
25
+
26
+ def recompute_overall(dimensions: list[dict[str, Any]], capped_by: list[str]) -> int:
27
+ scores = [int(d.get("score", 0)) for d in dimensions if d.get("key") in DIMENSION_KEYS]
28
+ if not scores:
29
+ return 0
30
+ overall = round(sum(scores) / len(scores) / 5 * 100)
31
+ if capped_by:
32
+ overall = min(overall, 39)
33
+ return overall
34
+
35
+
36
+ def merge_warnings(
37
+ llm_warnings: list[dict[str, Any]],
38
+ rule_warnings: list[dict[str, Any]],
39
+ ) -> list[dict[str, Any]]:
40
+ by_code: dict[str, dict[str, Any]] = {}
41
+ for w in llm_warnings:
42
+ code = w.get("code")
43
+ if code:
44
+ by_code[code] = w
45
+ for w in rule_warnings:
46
+ code = w.get("code")
47
+ if code:
48
+ by_code[code] = w # rule wins on conflict
49
+ return list(by_code.values())
50
+
51
+
52
+ def capped_by_from_warnings(warnings: list[dict[str, Any]]) -> list[str]:
53
+ return sorted({w["code"] for w in warnings if w.get("severity") == "critical" and w.get("code")})
54
+
55
+
56
+ def merge_audit(
57
+ llm_payload: dict[str, Any],
58
+ rule_warnings: list[dict[str, Any]],
59
+ ) -> dict[str, Any]:
60
+ brief = llm_payload.get("briefCheck") or {}
61
+ status = brief.get("status", "ok")
62
+
63
+ if status == "needs_clarification":
64
+ return {"briefCheck": brief, "auditReport": None}
65
+
66
+ audit = llm_payload.get("auditReport")
67
+ if not audit:
68
+ return {"briefCheck": brief, "auditReport": None}
69
+
70
+ ga = audit.get("goalAlignment") or {}
71
+ dimensions = ga.get("dimensions") or []
72
+ llm_warnings = audit.get("warnings") or audit.get("warnings_llm") or []
73
+ rewrite_hints = audit.get("rewriteHints") or []
74
+
75
+ warnings = merge_warnings(llm_warnings, rule_warnings)
76
+ capped = capped_by_from_warnings(warnings)
77
+ overall = recompute_overall(dimensions, capped)
78
+
79
+ goal_alignment = {
80
+ "overall": overall,
81
+ "cappedBy": capped,
82
+ "dimensions": dimensions,
83
+ "summary": ga.get("summary") or "",
84
+ }
85
+
86
+ return {
87
+ "briefCheck": brief,
88
+ "auditReport": {
89
+ "goalAlignment": goal_alignment,
90
+ "warnings": warnings,
91
+ "rewriteHints": rewrite_hints,
92
+ },
93
+ }
94
+
95
+
96
+ def viewer_payload(merged: dict[str, Any]) -> dict[str, Any]:
97
+ """Flatten merged output for post-audit-viewer (audit section only)."""
98
+ brief = merged.get("briefCheck")
99
+ audit = merged.get("auditReport")
100
+ if audit is None and brief:
101
+ return brief
102
+ if audit:
103
+ return audit
104
+ return merged
platform_config.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Platform-specific thresholds for rule linters."""
2
+
3
+ HASHTAG_LIMITS = {
4
+ "telegram": 3,
5
+ "linkedin": 5,
6
+ "x": 3,
7
+ "twitter": 3,
8
+ "other": 5,
9
+ }
10
+
11
+ ACTIVATING_GOAL_KEYWORDS = (
12
+ "register",
13
+ "sign up",
14
+ "signup",
15
+ "rsvp",
16
+ "join",
17
+ "attend",
18
+ "apply",
19
+ "submit",
20
+ "deadline",
21
+ "webinar",
22
+ "event",
23
+ "book",
24
+ "buy",
25
+ "donate",
26
+ "vote",
27
+ "enroll",
28
+ )
29
+
30
+ TIME_PATTERNS = (
31
+ r"\b\d{1,2}[/:]\d{2}\b",
32
+ r"\b\d{1,2}\s*(am|pm)\b",
33
+ r"\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b",
34
+ r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b",
35
+ r"\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b",
36
+ r"\bby\s+(tomorrow|tonight|eod|end of day)\b",
37
+ r"\b\d{4}-\d{2}-\d{2}\b",
38
+ )
39
+
40
+
41
+ def normalize_platform(platform: str) -> str:
42
+ p = (platform or "other").strip().lower()
43
+ if p in ("x/twitter", "x / twitter"):
44
+ return "x"
45
+ if p.startswith("twitter"):
46
+ return "twitter"
47
+ return p if p in HASHTAG_LIMITS else "other"
48
+
49
+
50
+ def hashtag_limit(platform: str) -> int:
51
+ return HASHTAG_LIMITS.get(normalize_platform(platform), HASHTAG_LIMITS["other"])
prompts.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM prompts for post audit (LLM-judgment codes only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from platform_config import hashtag_limit, normalize_platform
6
+
7
+ SYSTEM_PROMPT = """You are a social media editor. Audit the post against the stated goal and audience. Return STRICTLY valid JSON matching the schema below.
8
+ No text outside JSON: no preamble, no markdown fences, no comments. Response starts with { and ends with }.
9
+
10
+ INPUT (in the user message): platform, goal, audience, post.
11
+
12
+ STEP 0 — brief check (briefCheck).
13
+ - Goal is verifiable if there is an observable outcome and direction (not vague "engagement" but a concrete action or result).
14
+ - Audience is verifiable if role/level/context is clear (not "everyone" or "professionals").
15
+ - Always output inferred.goal and inferred.audience from the post text — your interpretation even if the brief is fine.
16
+ - If a brief field is vague, add a gaps object: field ("goal"|"audience"), reason, candidates (2–4 concrete options).
17
+ - status="ok" if audit is possible (including on inferred). status="needs_clarification" only if goal is completely unverifiable; then auditReport=null.
18
+
19
+ STEP 1 — goal dimensions (5 items, integer scores 1–5). Anchors:
20
+ [hook] 1=first line repels/empty; 3=neutral, not compelling; 5=stakes/benefit/hook clear from line one.
21
+ [clarity] 1=unclear what this is and why; 3=readable but scattered/multi-topic; 5=one clear core, easy to retell.
22
+ [audienceFit] 1=jargon/level wrong for audience; 3=mostly ok, some misses; 5=language, level, examples fit audience.
23
+ [goalService] 1=does not advance goal; 3=partial, gaps remain; 5=directly triggers actions stated in goal.
24
+ [cta] 1=no call to action; 3=present but vague or no deadline; 5=one clear CTA: action + deadline.
25
+ In rationale — one short phrase in the post's language explaining the score.
26
+
27
+ STEP 2 — warnings (warnings). Use ONLY codes from the catalog below (LLM judgment only — rule checks run separately on the host).
28
+ For each triggered warning: code, severity, source="llm", message (post language), evidence (verbatim excerpt from post ≤12 words; omit if not tied to a fragment).
29
+ severity ∈ {info, warning, critical}. Use critical only when the defect blocks the goal; GOAL_ACTION_MISMATCH is always critical.
30
+
31
+ CATALOG (code — default severity — when to fire):
32
+ GOAL_ACTION_MISMATCH — critical — post does not ask for the action stated in the goal.
33
+ BURIED_LEDE — warning — main value/motive buried at the end; critical if goal is activating.
34
+ GOAL_DRIFT — warning — ask is broader/different from declared goal.
35
+ WEAK_DELIVERABLE_NAMING— info — vague ask ("insights", "think about") instead of concrete deliverable.
36
+ NO_CLEAR_CTA — warning — no explicit CTA or CTA is logistics-only; critical if action is mandatory and absent.
37
+ DIFFUSE_ASKS — warning — ≥3 different asks in one post, focus blurred.
38
+ MIXED_MESSAGES — warning — artifact + task + logistics + philosophy mixed together.
39
+ EFFORT_ASYMMETRY — warning — sloppy/raw post while asking for careful work.
40
+ CLICKBAIT — warning — promise not backed by content; critical if strongly misleading.
41
+ TONE_MISMATCH — warning — tone/jargon level mismatches audience.
42
+ COGNITIVE_LOAD — warning — dense jargon + cross-references, hard to hold in mind.
43
+ AI_TELL — info — clichés like "in today's fast-changing world".
44
+
45
+ STEP 3 — goalAlignment assembly (host will recompute overall and cappedBy — still fill them for completeness).
46
+ - summary = 1–2 phrases in post language: what works and what pulls down.
47
+ - rewriteHints = 3–6 concrete edits in post language.
48
+
49
+ LANGUAGE: human-readable fields (rationale, message, summary, reason, candidates, rewriteHints, inferred.*) in the post's language. Keys and enums (code, severity, source, key, status, field) in Latin as in schema.
50
+
51
+ OUTPUT SCHEMA (return exactly one object):
52
+ {
53
+ "briefCheck": {
54
+ "status": "ok" | "needs_clarification",
55
+ "inferred": { "goal": string, "audience": string },
56
+ "gaps": [ { "field": "goal"|"audience", "reason": string, "candidates": [string, ...] } ]
57
+ },
58
+ "auditReport": {
59
+ "goalAlignment": {
60
+ "overall": integer,
61
+ "cappedBy": [string, ...],
62
+ "dimensions": [ { "key": "hook"|"clarity"|"audienceFit"|"goalService"|"cta", "score": 1..5, "rationale": string } ],
63
+ "summary": string
64
+ },
65
+ "warnings": [ { "code": string, "severity": "info"|"warning"|"critical", "source": "llm", "message": string, "evidence": string (optional) } ],
66
+ "rewriteHints": [string, ...]
67
+ } | null
68
+ }
69
+
70
+ HARD RULES: JSON only; scores integers 1–5; codes only from catalog; evidence verbatim from post; no extra fields; auditReport=null when status="needs_clarification"."""
71
+
72
+ FEW_SHOT_USER = """platform: telegram
73
+ goal: Register attendees for Thursday 7pm webinar
74
+ audience: Product managers who own product metrics
75
+ post:
76
+ Webinar on product metrics 🚀🚀 Link in bio!!! #product #metrics #growth #pm #webinar #training #analytics"""
77
+
78
+ FEW_SHOT_ASSISTANT = """{
79
+ "briefCheck": {
80
+ "status": "ok",
81
+ "inferred": { "goal": "Register for product metrics webinar (Thu 7pm)", "audience": "Product managers working with metrics" },
82
+ "gaps": []
83
+ },
84
+ "auditReport": {
85
+ "goalAlignment": {
86
+ "overall": 39,
87
+ "cappedBy": ["GOAL_ACTION_MISMATCH"],
88
+ "dimensions": [
89
+ { "key": "hook", "score": 2, "rationale": "Opens with generic 'webinar on metrics' — no benefit or hook." },
90
+ { "key": "clarity", "score": 3, "rationale": "Topic is clear but when/where/how to register is missing." },
91
+ { "key": "audienceFit", "score": 3, "rationale": "Topic fits PMs but tone is ad-like." },
92
+ { "key": "goalService", "score": 2, "rationale": "Cannot register: no link in post, no date/time." },
93
+ { "key": "cta", "score": 2, "rationale": "'Link in bio' is not an action; no registration form." }
94
+ ],
95
+ "summary": "Topic is clear but the post does not drive registration: no direct link, date, or clear CTA."
96
+ },
97
+ "warnings": [
98
+ { "code": "NO_CLEAR_CTA", "severity": "warning", "source": "llm", "message": "CTA is logistics ('link in bio'), not registration.", "evidence": "Link in bio" },
99
+ { "code": "GOAL_ACTION_MISMATCH", "severity": "critical", "source": "llm", "message": "Goal is registration but post does not enable signing up.", "evidence": "Link in bio" }
100
+ ],
101
+ "rewriteHints": [
102
+ "Put a direct registration link in the post, not 'in bio'.",
103
+ "State date and time (Thursday 7pm) in the text.",
104
+ "Replace ad tone with concrete takeaway: what attendees leave with.",
105
+ "Cut hashtags to 2–3 relevant ones."
106
+ ]
107
+ }
108
+ }"""
109
+
110
+
111
+ def build_user_message(platform: str, goal: str, audience: str, post: str) -> str:
112
+ plat = normalize_platform(platform)
113
+ limit = hashtag_limit(platform)
114
+ return f"""platform: {plat}
115
+ hashtag_limit: {limit}
116
+ goal: {goal}
117
+ audience: {audience}
118
+ post:
119
+ {post}"""
120
+
121
+
122
+ def build_messages(platform: str, goal: str, audience: str, post: str) -> list[dict[str, str]]:
123
+ return [
124
+ {"role": "system", "content": SYSTEM_PROMPT},
125
+ {"role": "user", "content": FEW_SHOT_USER},
126
+ {"role": "assistant", "content": FEW_SHOT_ASSISTANT},
127
+ {"role": "user", "content": build_user_message(platform, goal, audience, post)},
128
+ ]
render.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render audit JSON as HTML for Gradio (ported from post-audit-viewer.html)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from typing import Any
8
+
9
+ DIM_LABELS = {
10
+ "hook": "Hook",
11
+ "clarity": "Clarity",
12
+ "audienceFit": "Audience fit",
13
+ "goalService": "Goal service",
14
+ "cta": "Call to action",
15
+ }
16
+
17
+ SEV_ORDER = {"critical": 0, "warning": 1, "info": 2}
18
+ SEV_CLASS = {"critical": "crit", "warning": "warn", "info": "info"}
19
+
20
+
21
+ def _esc(s: Any) -> str:
22
+ return html.escape(str(s if s is not None else ""))
23
+
24
+
25
+ def _band_color(pct: int) -> str:
26
+ if pct < 40:
27
+ return "var(--crit)"
28
+ if pct < 70:
29
+ return "var(--warn)"
30
+ return "var(--ok)"
31
+
32
+
33
+ def _dim_color(score: int) -> str:
34
+ if score <= 2:
35
+ return "var(--crit)"
36
+ if score == 3:
37
+ return "var(--warn)"
38
+ return "var(--ok)"
39
+
40
+
41
+ def _gauge(pct: int) -> str:
42
+ r = 60
43
+ c = 2 * 3.14159265 * r
44
+ off = c * (1 - max(0, min(100, pct)) / 100)
45
+ col = _band_color(pct)
46
+ return f"""<div class="gauge">
47
+ <svg width="138" height="138" viewBox="0 0 138 138">
48
+ <circle cx="69" cy="69" r="{r}" fill="none" stroke="#ece6d8" stroke-width="9"/>
49
+ <circle cx="69" cy="69" r="{r}" fill="none" stroke="{col}" stroke-width="9" stroke-linecap="round"
50
+ stroke-dasharray="{c:.1f}" stroke-dashoffset="{off:.1f}" transform="rotate(-90 69 69)"/>
51
+ </svg>
52
+ <div class="num"><b style="color:{col}">{pct}</b><span>of 100</span></div>
53
+ </div>"""
54
+
55
+
56
+ def _render_audit(data: dict[str, Any]) -> str:
57
+ ga = data.get("goalAlignment") or {}
58
+ dims = ga.get("dimensions") or []
59
+ warns = sorted(
60
+ data.get("warnings") or [],
61
+ key=lambda w: SEV_ORDER.get(w.get("severity", "info"), 9),
62
+ )
63
+ counts: dict[str, int] = {}
64
+ for w in warns:
65
+ sev = w.get("severity", "info")
66
+ counts[sev] = counts.get(sev, 0) + 1
67
+ count_str = " · ".join(f"{counts[s]} {s}" for s in ("critical", "warning", "info") if counts.get(s))
68
+
69
+ parts = [
70
+ f"""<section class="sec"><div class="head">
71
+ {_gauge(int(ga.get("overall") or 0))}
72
+ <div class="lead">
73
+ <p class="verdict">{_esc(ga.get("summary") or "")}</p>"""
74
+ ]
75
+ capped = ga.get("cappedBy") or []
76
+ if capped:
77
+ tags = "".join(f'<span class="tag crit mono">{_esc(c)}</span>' for c in capped)
78
+ parts.append(f'<div class="capped"><span class="lbl">capped by:</span>{tags}</div>')
79
+ parts.append("</div></div></section>")
80
+
81
+ if dims:
82
+ parts.append(
83
+ '<section class="sec"><div class="sec-h"><span>Goal dimensions</span>'
84
+ '<span class="wcount">score / 5</span></div>'
85
+ )
86
+ for dm in dims:
87
+ s = int(dm.get("score") or 0)
88
+ pct = (s / 5) * 100
89
+ col = _dim_color(s)
90
+ key = dm.get("key", "")
91
+ parts.append(
92
+ f"""<div class="dim">
93
+ <div class="name">{_esc(DIM_LABELS.get(key, key))}<span class="k">{_esc(key)}</span></div>
94
+ <div class="body">
95
+ <div class="bar"><div class="track"><div class="fill" style="width:{pct}%;background:{col}"></div></div>
96
+ <div class="sc">{s}/5</div></div>
97
+ <div class="rat">{_esc(dm.get("rationale") or "")}</div>
98
+ </div></div>"""
99
+ )
100
+ parts.append("</section>")
101
+
102
+ if warns:
103
+ parts.append(
104
+ f'<section class="sec"><div class="sec-h"><span>Warnings</span>'
105
+ f'<span class="wcount">{_esc(count_str)}</span></div>'
106
+ )
107
+ for i, w in enumerate(warns):
108
+ cl = SEV_CLASS.get(w.get("severity", "info"), "info")
109
+ src = f'<span class="src">{_esc(w.get("source"))}</span>' if w.get("source") else ""
110
+ ev = (
111
+ f'<div class="ev">{_esc(w.get("evidence"))}</div>'
112
+ if w.get("evidence")
113
+ else ""
114
+ )
115
+ parts.append(
116
+ f"""<div class="w {cl}" style="--i:{i}">
117
+ <div class="row">
118
+ <span class="sev {cl}">{_esc(w.get("severity"))}</span>
119
+ <span class="code">{_esc(w.get("code"))}</span>
120
+ {src}
121
+ </div>
122
+ <div class="msg">{_esc(w.get("message"))}</div>
123
+ {ev}
124
+ </div>"""
125
+ )
126
+ parts.append("</section>")
127
+
128
+ hints = data.get("rewriteHints") or []
129
+ if hints:
130
+ items = "".join(f"<li>{_esc(h)}</li>" for h in hints)
131
+ parts.append(
132
+ f'<section class="sec"><div class="sec-h"><span>Rewrite hints</span></div>'
133
+ f'<ol class="hints">{items}</ol></section>'
134
+ )
135
+
136
+ return "".join(parts)
137
+
138
+
139
+ def _render_brief(data: dict[str, Any]) -> str:
140
+ ok = data.get("status") == "ok"
141
+ status_cls = "ok" if ok else "clar"
142
+ status_txt = (
143
+ "Brief accepted — audit uses inferred fields"
144
+ if ok
145
+ else "Needs clarification"
146
+ )
147
+ inferred = data.get("inferred") or {}
148
+ parts = [
149
+ f"""<section class="sec">
150
+ <span class="status {status_cls}"><span class="dot"></span>{status_txt}</span>
151
+ <div class="cards2" style="margin-top:16px">
152
+ <div class="infcard"><div class="t">Goal (inferred)</div><div class="v">{_esc(inferred.get("goal") or "—")}</div></div>
153
+ <div class="infcard"><div class="t">Audience (inferred)</div><div class="v">{_esc(inferred.get("audience") or "—")}</div></div>
154
+ </div></section>"""
155
+ ]
156
+ for g in data.get("gaps") or []:
157
+ chips = "".join(f'<span class="chip">{_esc(c)}</span>' for c in g.get("candidates") or [])
158
+ parts.append(
159
+ f"""<section class="sec"><div class="sec-h"><span>Gaps</span></div>
160
+ <div class="gap">
161
+ <span class="fld">{_esc(g.get("field"))}</span>
162
+ <div class="reason">{_esc(g.get("reason") or "")}</div>
163
+ <div class="chips">{chips}</div>
164
+ </div></section>"""
165
+ )
166
+ return "".join(parts)
167
+
168
+
169
+ REPORT_CSS = """
170
+ :root{
171
+ --paper:#f5f1e8; --card:#fffdf8; --ink:#211f1b; --soft:#5c574d; --faint:#928c7e;
172
+ --rule:#e2dccd; --rule-2:#d3ccb8;
173
+ --crit:#b3261e; --crit-bg:#fbe9e6; --warn:#956000; --warn-bg:#faf0d6; --info:#2b5f8a; --info-bg:#e6eef6;
174
+ --ok:#2f6b34; --ok-bg:#e8f1e3;
175
+ }
176
+ .post-audit-report *{box-sizing:border-box}
177
+ .post-audit-report{
178
+ background:var(--paper); color:var(--ink);
179
+ font-family:"Golos Text",system-ui,sans-serif; font-size:16px; line-height:1.6;
180
+ border:1px solid var(--rule); border-radius:14px; padding:24px 20px;
181
+ }
182
+ .post-audit-report .mono{font-family:"JetBrains Mono",monospace}
183
+ .post-audit-report header.top{border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:20px}
184
+ .post-audit-report header.top .kicker{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint)}
185
+ .post-audit-report header.top h1{font-family:Georgia,serif;font-weight:700;font-size:28px;line-height:1.1;margin:6px 0 0}
186
+ .post-audit-report .sec{margin-top:28px}
187
+ .post-audit-report .sec-h{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);
188
+ border-bottom:1px solid var(--rule);padding-bottom:8px;margin-bottom:18px;display:flex;justify-content:space-between;align-items:baseline}
189
+ .post-audit-report .head{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
190
+ .post-audit-report .gauge{flex:0 0 auto;position:relative;width:138px;height:138px}
191
+ .post-audit-report .gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
192
+ .post-audit-report .gauge .num b{font-family:Georgia,serif;font-size:42px;font-weight:700;line-height:1}
193
+ .post-audit-report .gauge .num span{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;margin-top:2px}
194
+ .post-audit-report .head .lead{flex:1 1 280px;min-width:220px}
195
+ .post-audit-report .head .lead .verdict{font-family:Georgia,serif;font-size:20px;line-height:1.32;font-weight:500;margin:0 0 12px}
196
+ .post-audit-report .capped{display:flex;flex-wrap:wrap;gap:6px}
197
+ .post-audit-report .capped .lbl{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;align-self:center;margin-right:2px}
198
+ .post-audit-report .tag{font-family:"JetBrains Mono",monospace;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid;letter-spacing:.02em}
199
+ .post-audit-report .tag.crit{color:var(--crit);background:var(--crit-bg);border-color:#eecfca}
200
+ .post-audit-report .dim{display:grid;grid-template-columns:150px 1fr;gap:14px 18px;align-items:start;padding:14px 0;border-bottom:1px solid var(--rule)}
201
+ .post-audit-report .dim:last-child{border-bottom:none}
202
+ .post-audit-report .dim .name{font-weight:600;font-size:15px}
203
+ .post-audit-report .dim .name .k{display:block;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--faint);font-weight:400;margin-top:2px}
204
+ .post-audit-report .dim .body .bar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
205
+ .post-audit-report .track{flex:1;height:7px;border-radius:4px;background:#ece6d8;overflow:hidden}
206
+ .post-audit-report .fill{height:100%;border-radius:4px}
207
+ .post-audit-report .dim .sc{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);min-width:30px;text-align:right}
208
+ .post-audit-report .dim .rat{font-size:14px;color:var(--soft);line-height:1.5}
209
+ .post-audit-report .wcount{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--faint);letter-spacing:.04em}
210
+ .post-audit-report .w{position:relative;background:var(--card);border:1px solid var(--rule);border-left-width:4px;border-radius:10px;padding:13px 16px 15px;margin-bottom:11px}
211
+ .post-audit-report .w.crit{border-left-color:var(--crit)} .post-audit-report .w.warn{border-left-color:var(--warn)} .post-audit-report .w.info{border-left-color:var(--info)}
212
+ .post-audit-report .w .row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px}
213
+ .post-audit-report .sev{font-family:"JetBrains Mono",monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px 7px;border-radius:5px;font-weight:500}
214
+ .post-audit-report .sev.crit{color:var(--crit);background:var(--crit-bg)} .post-audit-report .sev.warn{color:var(--warn);background:var(--warn-bg)} .post-audit-report .sev.info{color:var(--info);background:var(--info-bg)}
215
+ .post-audit-report .code{font-family:"JetBrains Mono",monospace;font-size:12.5px;font-weight:500}
216
+ .post-audit-report .src{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--faint);border:1px solid var(--rule-2);border-radius:20px;padding:2px 9px}
217
+ .post-audit-report .w .msg{font-size:14.5px;line-height:1.5}
218
+ .post-audit-report .ev{margin-top:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);background:#f3eee2;border-radius:7px;padding:8px 11px;border-left:2px solid var(--rule-2);white-space:pre-wrap;word-break:break-word}
219
+ .post-audit-report .ev::before{content:"evidence";display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:4px}
220
+ .post-audit-report ol.hints{list-style:none;counter-reset:h;margin:0;padding:0}
221
+ .post-audit-report ol.hints li{counter-increment:h;position:relative;padding:11px 0 11px 42px;border-bottom:1px solid var(--rule);font-size:15px;line-height:1.5;color:var(--ink)}
222
+ .post-audit-report ol.hints li:last-child{border-bottom:none}
223
+ .post-audit-report ol.hints li::before{content:counter(h,decimal-leading-zero);position:absolute;left:0;top:11px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--faint);font-weight:500}
224
+ .post-audit-report .cards2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
225
+ @media(max-width:620px){.post-audit-report .cards2{grid-template-columns:1fr}.post-audit-report .dim{grid-template-columns:1fr}}
226
+ .post-audit-report .infcard{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px}
227
+ .post-audit-report .infcard .t{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:7px}
228
+ .post-audit-report .infcard .v{font-size:15px;line-height:1.5}
229
+ .post-audit-report .gap{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px;margin-top:13px}
230
+ .post-audit-report .gap .fld{display:inline-block;font-family:"JetBrains Mono",monospace;font-size:11px;background:var(--warn-bg);color:var(--warn);padding:3px 8px;border-radius:5px;margin-bottom:8px}
231
+ .post-audit-report .gap .reason{font-size:14.5px;color:var(--soft);margin-bottom:11px}
232
+ .post-audit-report .chips{display:flex;flex-wrap:wrap;gap:7px}
233
+ .post-audit-report .chip{font-size:13px;border:1px solid var(--rule-2);border-radius:20px;padding:5px 13px;background:#fcfaf4}
234
+ .post-audit-report .status{display:inline-flex;align-items:center;gap:8px;font-family:"JetBrains Mono",monospace;font-size:12px;padding:6px 13px;border-radius:8px;font-weight:500}
235
+ .post-audit-report .status.ok{color:var(--ok);background:var(--ok-bg)} .post-audit-report .status.clar{color:var(--warn);background:var(--warn-bg)}
236
+ .post-audit-report .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
237
+ """
238
+
239
+
240
+ def render_report_html(payload: dict[str, Any]) -> str:
241
+ """Build full HTML fragment for gr.HTML from merged or viewer payload."""
242
+ if payload.get("goalAlignment"):
243
+ body = _render_audit(payload)
244
+ subtitle = "Structured audit against your stated goal and audience."
245
+ elif payload.get("status") or payload.get("inferred"):
246
+ body = _render_brief(payload)
247
+ subtitle = "Brief check — clarify gaps before a full audit."
248
+ else:
249
+ body = f'<p class="rat">Unexpected payload: {_esc(json.dumps(payload)[:200])}</p>'
250
+ subtitle = "Report"
251
+
252
+ return f"""<div class="post-audit-report">
253
+ <style>{REPORT_CSS}</style>
254
+ <header class="top">
255
+ <div class="kicker">post audit · pipeline output</div>
256
+ <h1>Post audit report</h1>
257
+ <p>{_esc(subtitle)}</p>
258
+ </header>
259
+ {body}
260
+ </div>"""
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Pinned to match the HF Space SDK version in README.md frontmatter (sdk_version).
2
+ # Keep these two in sync so local dev runs the same Gradio as the deployed Space.
3
+ gradio==5.12.0
4
+ # gradio 5.12.0 imports `requests` from its CLI on `import gradio` but does not
5
+ # declare it; the HF base image ships requests, a bare local venv does not.
6
+ requests>=2.31.0
rules.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic rule-based warnings for social post audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from platform_config import ACTIVATING_GOAL_KEYWORDS, TIME_PATTERNS, hashtag_limit, normalize_platform
9
+
10
+ URL_RE = re.compile(r"https?://\S+", re.I)
11
+ HASHTAG_RE = re.compile(r"#\w+", re.UNICODE)
12
+ CHAT_TIMESTAMP_RE = re.compile(
13
+ r"\[\d{1,2}/\d{1,2}/\d{2,4}\s+\d{1,2}:\d{2}\s*(?:AM|PM)?\]",
14
+ re.I,
15
+ )
16
+ CHAT_NAME_RE = re.compile(r"^\[[^\]]+\]\s+\w[\w\s.-]{0,40}:", re.M)
17
+ ENGAGEMENT_BAIT_RE = re.compile(
18
+ r"\b(tag a friend|comment below|double tap|like and share|drop a .? below|"
19
+ r"share this post|repost if you agree)\b",
20
+ re.I,
21
+ )
22
+ STRUCTURE_MARKERS_RE = re.compile(
23
+ r"(^[\s]*[-*•]\s|\n[\s]*[-*•]\s|^\d+\.\s|\n\d+\.\s|^#{1,3}\s|\*\*[^*]+\*\*)",
24
+ re.M,
25
+ )
26
+
27
+
28
+ def detect_post_language(post: str) -> str:
29
+ if not post.strip():
30
+ return "en"
31
+ cyrillic = sum(1 for c in post if "\u0400" <= c <= "\u04FF")
32
+ return "ru" if cyrillic > len(post) * 0.15 else "en"
33
+
34
+
35
+ def _msg(lang: str, en: str, ru: str) -> str:
36
+ return ru if lang == "ru" else en
37
+
38
+
39
+ def _warning(
40
+ code: str,
41
+ severity: str,
42
+ message: str,
43
+ evidence: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ w: dict[str, Any] = {
46
+ "code": code,
47
+ "severity": severity,
48
+ "source": "rule",
49
+ "message": message,
50
+ }
51
+ if evidence:
52
+ w["evidence"] = evidence[:120]
53
+ return w
54
+
55
+
56
+ def _first_line(post: str) -> str:
57
+ lines = [ln.strip() for ln in post.strip().splitlines() if ln.strip()]
58
+ return lines[0] if lines else ""
59
+
60
+
61
+ def _evidence_snippet(text: str, max_words: int = 12) -> str:
62
+ words = text.split()
63
+ return " ".join(words[:max_words])
64
+
65
+
66
+ def _goal_is_activating(goal: str) -> bool:
67
+ g = goal.lower()
68
+ return any(kw in g for kw in ACTIVATING_GOAL_KEYWORDS)
69
+
70
+
71
+ def _post_has_deadline(post: str) -> bool:
72
+ for pat in TIME_PATTERNS:
73
+ if re.search(pat, post, re.I):
74
+ return True
75
+ return False
76
+
77
+
78
+ def _check_hashtag_stuffing(post: str, platform: str, lang: str) -> dict[str, Any] | None:
79
+ tags = HASHTAG_RE.findall(post)
80
+ limit = hashtag_limit(platform)
81
+ if len(tags) <= limit:
82
+ return None
83
+ return _warning(
84
+ "HASHTAG_STUFFING",
85
+ "warning",
86
+ _msg(
87
+ lang,
88
+ f"{len(tags)} hashtags exceed the {normalize_platform(platform)} limit ({limit}).",
89
+ f"{len(tags)} хэштегов — больше порога для {normalize_platform(platform)} ({limit}).",
90
+ ),
91
+ _evidence_snippet(" ".join(tags[:6])),
92
+ )
93
+
94
+
95
+ def _check_chat_dump(post: str, lang: str) -> dict[str, Any] | None:
96
+ m = CHAT_TIMESTAMP_RE.search(post) or CHAT_NAME_RE.search(post)
97
+ if not m:
98
+ return None
99
+ return _warning(
100
+ "CHAT_DUMP_FORMAT",
101
+ "warning",
102
+ _msg(
103
+ lang,
104
+ "Timestamps or chat-style name prefixes look like a pasted conversation, not a composed post.",
105
+ "Таймстемпы и подписи «Имя:» — похоже на копипаст чата, а не собранный пост.",
106
+ ),
107
+ _evidence_snippet(m.group(0)),
108
+ )
109
+
110
+
111
+ def _check_weak_opening(post: str, lang: str) -> dict[str, Any] | None:
112
+ first = _first_line(post)
113
+ if not first:
114
+ return None
115
+ if URL_RE.fullmatch(first.strip()) or (
116
+ URL_RE.search(first) and len(first.split()) <= 4
117
+ ):
118
+ return _warning(
119
+ "WEAK_OPENING",
120
+ "warning",
121
+ _msg(
122
+ lang,
123
+ "First line is a bare link or too thin to hook the reader.",
124
+ "Первая строка — голая ссылка или слабая зацепка.",
125
+ ),
126
+ _evidence_snippet(first),
127
+ )
128
+ return None
129
+
130
+
131
+ def _check_wall_of_text(post: str, lang: str) -> dict[str, Any] | None:
132
+ for para in post.split("\n\n"):
133
+ p = para.strip()
134
+ if len(p) > 400 and "\n" not in p:
135
+ return _warning(
136
+ "WALL_OF_TEXT",
137
+ "warning",
138
+ _msg(
139
+ lang,
140
+ "Long paragraph without line breaks is hard to scan.",
141
+ "Длинный абзац без переносов — тяжело читать.",
142
+ ),
143
+ _evidence_snippet(p),
144
+ )
145
+ return None
146
+
147
+
148
+ def _check_no_structure(post: str, lang: str) -> dict[str, Any] | None:
149
+ if len(post) < 280:
150
+ return None
151
+ if STRUCTURE_MARKERS_RE.search(post):
152
+ return None
153
+ if post.count("\n") >= 4:
154
+ return None
155
+ return _warning(
156
+ "NO_STRUCTURE",
157
+ "warning",
158
+ _msg(
159
+ lang,
160
+ "Long post lacks bullets, numbers, or headings — reads as a flat dump.",
161
+ "Длинный пост без списков и иерархии — плоский дамп.",
162
+ ),
163
+ _evidence_snippet(_first_line(post) or post[:80]),
164
+ )
165
+
166
+
167
+ def _check_engagement_bait(post: str, lang: str) -> dict[str, Any] | None:
168
+ m = ENGAGEMENT_BAIT_RE.search(post)
169
+ if not m:
170
+ return None
171
+ return _warning(
172
+ "ENGAGEMENT_BAIT",
173
+ "warning",
174
+ _msg(
175
+ lang,
176
+ "Engagement-bait phrasing detected.",
177
+ "Обнаружена «приманка» для вовлечения.",
178
+ ),
179
+ _evidence_snippet(m.group(0)),
180
+ )
181
+
182
+
183
+ def _check_bare_link(post: str, lang: str) -> dict[str, Any] | None:
184
+ urls = URL_RE.findall(post)
185
+ if not urls:
186
+ return None
187
+ for url in urls:
188
+ idx = post.find(url)
189
+ before = post[max(0, idx - 80) : idx].strip()
190
+ after = post[idx + len(url) : idx + len(url) + 80].strip()
191
+ context = (before + " " + after).strip()
192
+ if len(context.split()) < 6:
193
+ return _warning(
194
+ "BARE_LINK",
195
+ "info",
196
+ _msg(
197
+ lang,
198
+ "Link appears without framing — say what's inside and why to open it.",
199
+ "Ссылка без рамки — неясно, что внутри и зачем открывать.",
200
+ ),
201
+ _evidence_snippet(url),
202
+ )
203
+ return None
204
+
205
+
206
+ def _check_dense_parenthetical(post: str, lang: str) -> dict[str, Any] | None:
207
+ for m in re.finditer(r"\([^)]{80,}\)", post):
208
+ return _warning(
209
+ "DENSE_PARENTHETICAL",
210
+ "info",
211
+ _msg(
212
+ lang,
213
+ "Long parenthetical breaks reading flow.",
214
+ "Длинная вставка в скобках тормозит чтение.",
215
+ ),
216
+ _evidence_snippet(m.group(0)),
217
+ )
218
+ return None
219
+
220
+
221
+ def _check_no_deadline(post: str, goal: str, lang: str) -> dict[str, Any] | None:
222
+ if not _goal_is_activating(goal):
223
+ return None
224
+ if _post_has_deadline(post):
225
+ return None
226
+ return _warning(
227
+ "NO_DEADLINE",
228
+ "warning",
229
+ _msg(
230
+ lang,
231
+ "Goal implies a time-bound action, but the post has no date or deadline.",
232
+ "Цель требует действия ко времени, но в посте нет срока.",
233
+ ),
234
+ _evidence_snippet(_first_line(post) or post[:60]),
235
+ )
236
+
237
+
238
+ def _check_unresolved_reference(post: str, lang: str) -> dict[str, Any] | None:
239
+ refs = re.search(
240
+ r"\b(link in bio|link in profile|see profile|check my profile|"
241
+ r"registration link|sign up link)\b",
242
+ post,
243
+ re.I,
244
+ )
245
+ if not refs:
246
+ return None
247
+ if URL_RE.search(post):
248
+ return None
249
+ return _warning(
250
+ "UNRESOLVED_REFERENCE",
251
+ "info",
252
+ _msg(
253
+ lang,
254
+ "Registration or resource is referenced but no URL is in the post.",
255
+ "Регистрация упомянута, но ссылки в тексте нет.",
256
+ ),
257
+ _evidence_snippet(refs.group(0)),
258
+ )
259
+
260
+
261
+ def run_rules(
262
+ platform: str,
263
+ goal: str,
264
+ audience: str,
265
+ post: str,
266
+ ) -> list[dict[str, Any]]:
267
+ """Run all rule linters and return warning objects."""
268
+ del audience # reserved for future rule checks
269
+ lang = detect_post_language(post)
270
+ checks = [
271
+ _check_hashtag_stuffing(post, platform, lang),
272
+ _check_chat_dump(post, lang),
273
+ _check_weak_opening(post, lang),
274
+ _check_wall_of_text(post, lang),
275
+ _check_no_structure(post, lang),
276
+ _check_engagement_bait(post, lang),
277
+ _check_bare_link(post, lang),
278
+ _check_dense_parenthetical(post, lang),
279
+ _check_no_deadline(post, goal, lang),
280
+ _check_unresolved_reference(post, lang),
281
+ ]
282
+ return [w for w in checks if w is not None]
static/viewer.html ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Post Audit — Report Viewer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;0,700;1,500&family=Golos+Text:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --paper:#f5f1e8; --card:#fffdf8; --ink:#211f1b; --soft:#5c574d; --faint:#928c7e;
13
+ --rule:#e2dccd; --rule-2:#d3ccb8;
14
+ --crit:#b3261e; --crit-bg:#fbe9e6; --warn:#956000; --warn-bg:#faf0d6; --info:#2b5f8a; --info-bg:#e6eef6;
15
+ --ok:#2f6b34; --ok-bg:#e8f1e3;
16
+ }
17
+ *{box-sizing:border-box}
18
+ html,body{margin:0}
19
+ body{
20
+ background:var(--paper); color:var(--ink);
21
+ font-family:"Golos Text",sans-serif; font-size:16px; line-height:1.6;
22
+ -webkit-font-smoothing:antialiased;
23
+ background-image:radial-gradient(circle at 18% -10%,#fbf8f0 0,transparent 46%);
24
+ }
25
+ .wrap{max-width:900px;margin:0 auto;padding:38px 26px 90px}
26
+ a{color:var(--info)}
27
+ .mono{font-family:"JetBrains Mono",monospace}
28
+
29
+ /* masthead */
30
+ header.top{border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:26px}
31
+ header.top .kicker{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint)}
32
+ header.top h1{font-family:"Lora",serif;font-weight:700;font-size:34px;line-height:1.04;margin:6px 0 0;letter-spacing:-.01em}
33
+ header.top p{margin:7px 0 0;color:var(--soft);font-size:14.5px;max-width:60ch}
34
+
35
+ /* input */
36
+ .io{background:var(--card);border:1px solid var(--rule);border-radius:14px;padding:16px;margin-bottom:30px}
37
+ .io summary{cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;align-items:center;gap:8px}
38
+ .io summary::-webkit-details-marker{display:none}
39
+ .io summary .chev{transition:transform .2s;color:var(--faint)}
40
+ details[open] .io-body{margin-top:14px}
41
+ textarea{width:100%;min-height:150px;resize:vertical;border:1px solid var(--rule-2);border-radius:9px;
42
+ padding:12px 13px;font-family:"JetBrains Mono",monospace;font-size:12.5px;line-height:1.55;background:#fcfaf4;color:var(--ink)}
43
+ textarea:focus{outline:none;border-color:var(--ink)}
44
+ .btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
45
+ button{font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;border-radius:8px;padding:8px 14px;border:1px solid var(--rule-2);background:#fcfaf4;color:var(--ink);transition:.15s}
46
+ button:hover{border-color:var(--ink)}
47
+ button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
48
+ button.primary:hover{opacity:.88}
49
+ .err{color:var(--crit);font-size:13px;margin-top:10px;font-family:"JetBrains Mono",monospace;display:none}
50
+
51
+ /* sections */
52
+ .sec{margin-top:34px}
53
+ .sec-h{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);
54
+ border-bottom:1px solid var(--rule);padding-bottom:8px;margin-bottom:18px;display:flex;justify-content:space-between;align-items:baseline}
55
+
56
+ /* score head */
57
+ .head{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
58
+ .gauge{flex:0 0 auto;position:relative;width:138px;height:138px}
59
+ .gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
60
+ .gauge .num b{font-family:"Lora",serif;font-size:42px;font-weight:700;line-height:1}
61
+ .gauge .num span{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;margin-top:2px}
62
+ .head .lead{flex:1 1 280px;min-width:260px}
63
+ .head .lead .verdict{font-family:"Lora",serif;font-size:21px;line-height:1.32;font-weight:500;margin:0 0 12px}
64
+ .capped{display:flex;flex-wrap:wrap;gap:6px}
65
+ .capped .lbl{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;align-self:center;margin-right:2px}
66
+ .tag{font-family:"JetBrains Mono",monospace;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid;letter-spacing:.02em}
67
+ .tag.crit{color:var(--crit);background:var(--crit-bg);border-color:#eecfca}
68
+
69
+ /* dimensions */
70
+ .dim{display:grid;grid-template-columns:150px 1fr;gap:14px 18px;align-items:start;padding:14px 0;border-bottom:1px solid var(--rule)}
71
+ .dim:last-child{border-bottom:none}
72
+ .dim .name{font-weight:600;font-size:15px}
73
+ .dim .name .k{display:block;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--faint);font-weight:400;margin-top:2px}
74
+ .dim .body .bar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
75
+ .track{flex:1;height:7px;border-radius:4px;background:#ece6d8;overflow:hidden}
76
+ .fill{height:100%;border-radius:4px;transform-origin:left;animation:grow .7s cubic-bezier(.22,1,.36,1) both}
77
+ @keyframes grow{from{transform:scaleX(0)}}
78
+ .dim .sc{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);min-width:30px;text-align:right}
79
+ .dim .rat{font-size:14px;color:var(--soft);line-height:1.5}
80
+
81
+ /* warnings */
82
+ .wcount{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--faint);letter-spacing:.04em}
83
+ .w{position:relative;background:var(--card);border:1px solid var(--rule);border-left-width:4px;border-radius:10px;
84
+ padding:13px 16px 15px;margin-bottom:11px;animation:rise .5s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--i,0)*55ms)}
85
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}}
86
+ .w.crit{border-left-color:var(--crit)} .w.warn{border-left-color:var(--warn)} .w.info{border-left-color:var(--info)}
87
+ .w .row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px}
88
+ .sev{font-family:"JetBrains Mono",monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px 7px;border-radius:5px;font-weight:500}
89
+ .sev.crit{color:var(--crit);background:var(--crit-bg)} .sev.warn{color:var(--warn);background:var(--warn-bg)} .sev.info{color:var(--info);background:var(--info-bg)}
90
+ .code{font-family:"JetBrains Mono",monospace;font-size:12.5px;font-weight:500}
91
+ .src{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--faint);border:1px solid var(--rule-2);border-radius:20px;padding:2px 9px}
92
+ .w .msg{font-size:14.5px;line-height:1.5}
93
+ .ev{margin-top:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);background:#f3eee2;border-radius:7px;padding:8px 11px;border-left:2px solid var(--rule-2);white-space:pre-wrap;word-break:break-word}
94
+ .ev::before{content:"evidence";display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:4px}
95
+
96
+ /* hints */
97
+ ol.hints{list-style:none;counter-reset:h;margin:0;padding:0}
98
+ ol.hints li{counter-increment:h;position:relative;padding:11px 0 11px 42px;border-bottom:1px solid var(--rule);font-size:15px;line-height:1.5;color:var(--ink)}
99
+ ol.hints li:last-child{border-bottom:none}
100
+ ol.hints li::before{content:counter(h,decimal-leading-zero);position:absolute;left:0;top:11px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--faint);font-weight:500}
101
+
102
+ /* brief view */
103
+ .cards2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
104
+ @media(max-width:620px){.cards2{grid-template-columns:1fr}.dim{grid-template-columns:1fr}}
105
+ .infcard{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px}
106
+ .infcard .t{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:7px}
107
+ .infcard .v{font-size:15px;line-height:1.5}
108
+ .gap{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px;margin-top:13px}
109
+ .gap .fld{display:inline-block;font-family:"JetBrains Mono",monospace;font-size:11px;background:var(--warn-bg);color:var(--warn);padding:3px 8px;border-radius:5px;margin-bottom:8px}
110
+ .gap .reason{font-size:14.5px;color:var(--soft);margin-bottom:11px}
111
+ .chips{display:flex;flex-wrap:wrap;gap:7px}
112
+ .chip{font-size:13px;border:1px solid var(--rule-2);border-radius:20px;padding:5px 13px;background:#fcfaf4}
113
+ .status{display:inline-flex;align-items:center;gap:8px;font-family:"JetBrains Mono",monospace;font-size:12px;padding:6px 13px;border-radius:8px;font-weight:500}
114
+ .status.ok{color:var(--ok);background:var(--ok-bg)} .status.clar{color:var(--warn);background:var(--warn-bg)}
115
+ .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
116
+ </style>
117
+ </head>
118
+ <body>
119
+ <div class="wrap">
120
+ <header class="top">
121
+ <div class="kicker">post audit · pipeline output</div>
122
+ <h1>Post audit report</h1>
123
+ <p>Paste pipeline JSON — <span class="mono">AuditReport</span> or <span class="mono">BriefCheck</span>. View is detected automatically.</p>
124
+ </header>
125
+
126
+ <details class="io" open>
127
+ <summary><span class="chev">▾</span> Input JSON</summary>
128
+ <div class="io-body">
129
+ <textarea id="src" spellcheck="false"></textarea>
130
+ <div class="btns">
131
+ <button class="primary" id="render">Render</button>
132
+ <button data-sample="audit">Sample: audit</button>
133
+ <button data-sample="brief">Sample: brief</button>
134
+ <button id="clear">Clear</button>
135
+ </div>
136
+ <div class="err" id="err"></div>
137
+ </div>
138
+ </details>
139
+
140
+ <div id="out"></div>
141
+ </div>
142
+
143
+ <script type="application/json" id="sample-audit">
144
+ {
145
+ "goalAlignment": {
146
+ "overall": 34,
147
+ "cappedBy": ["BURIED_LEDE", "GOAL_ACTION_MISMATCH"],
148
+ "dimensions": [
149
+ { "key": "hook", "score": 2, "rationale": "Открывается голой ссылкой и «Кондуит zero-version:». Ни ставки, ни «зачем мне это»." },
150
+ { "key": "clarity", "score": 2, "rationale": "Артефакт + задача + логистика + философия смешаны в одном дампе из нескольких сообщений." },
151
+ { "key": "audienceFit", "score": 3, "rationale": "Жаргон уместен для группы в контексте, но сырость и отсутствие структуры не уважают внимание." },
152
+ { "key": "goalService", "score": 2, "rationale": "Материалы и черновик для реакции даны (плюс), но три заявленных действия пост напрямую не запускает." },
153
+ { "key": "cta", "score": 2, "rationale": "Призыв = логистика (куда писать), без дедлайна и персонального действия." }
154
+ ],
155
+ "summary": "Пост даёт материал для работы, но как мотиватор не собран: причина действовать спрятана в конце, а просьба не совпадает с целью."
156
+ },
157
+ "warnings": [
158
+ { "code": "BURIED_LEDE", "severity": "critical", "source": "llm", "message": "Мотивирующая рамка — самый сильный аргумент действовать — стоит в самом конце.", "evidence": "Любые и все важные решения так или иначе будут приняты" },
159
+ { "code": "GOAL_ACTION_MISMATCH", "severity": "critical", "source": "llm", "message": "Цель требует «написать свою версию кондуита», но пост этого не просит — ставит общую задачу «улучшить и превратить в схему».", "evidence": "вот этот наш кондуит улучшить и ... превратить в схему" },
160
+ { "code": "NO_CLEAR_CTA", "severity": "warning", "source": "llm", "message": "Призыв описывает, куда писать, но не что именно сделать каждому и к какому сроку.", "evidence": "Содержательные мысли лучше добавлять на miro-доску" },
161
+ { "code": "EFFORT_ASYMMETRY", "severity": "warning", "source": "llm", "message": "Сырой дамп при просьбе о вдумчивой работе демотивирует: «автор не вложился — почему должен я»." },
162
+ { "code": "WEAK_OPENING", "severity": "warning", "source": "rule", "message": "Первая строка — голая ссылка; на обрезке «…ещё» зацепки нет.", "evidence": "Справочник (https://agsagds.github.io/...) с чеклистами" },
163
+ { "code": "CHAT_DUMP_FORMAT", "severity": "warning", "source": "rule", "message": "Вставлены таймстемпы и подписи — выглядит как копипаст истории чата, а не собранный пост.", "evidence": "[6/5/26 5:10 PM] Pavel Trubin:" },
164
+ { "code": "TYPOS", "severity": "warning", "source": "rule", "message": "Опечатки бьют по доверию в посте, который просит о тщательной работе.", "evidence": "учестом · принтяия · принтяием · данны" },
165
+ { "code": "NO_STRUCTURE", "severity": "warning", "source": "rule", "message": "«Кондуит zero-version» — плоский список фрагментов без иерархии; тяжело реагировать построчно." }
166
+ ],
167
+ "rewriteHints": [
168
+ "Поднять рамку в начало: решения принимаются всегда, сложности по обе стороны от момента — это и есть зацепка.",
169
+ "Явный персональный CTA под цель: «прочитай справочник → напиши свою версию кондуита (3–5 строк) → предложи одну правку схемы», с дедлайном.",
170
+ "Развести артефакт / задачу / логистику; кондуит структурировать в список с иерархией.",
171
+ "Дать ссылке рамку: что внутри и зачем открыть до того, как писать свою версию.",
172
+ "Вычитать опечатки — особенно когда просишь о вдумчивой работе."
173
+ ]
174
+ }
175
+ </script>
176
+
177
+ <script type="application/json" id="sample-brief">
178
+ {
179
+ "status": "ok",
180
+ "inferred": {
181
+ "goal": "Активировать участников на 3 действия: прочитать материалы, написать свою версию кондуита, доработать схему",
182
+ "audience": "Рабочая группа по методологии принятия решений — коллеги в контексте, знают термины ЛПР / онтология / кондуит"
183
+ },
184
+ "gaps": [
185
+ { "field": "audience", "reason": "Задана как «участники» без уровня/контекста; выведена из текста, но не подтверждена", "candidates": ["Коллеги глубоко в ко��тексте", "Новые участники, нужен ввод в тему", "Смешанная группа"] }
186
+ ]
187
+ }
188
+ </script>
189
+
190
+ <script>
191
+ const DIM_LABELS = { hook:"Hook", clarity:"Clarity", audienceFit:"Audience fit", goalService:"Goal service", cta:"Call to action" };
192
+ const SEV_ORDER = { critical:0, warning:1, info:2 };
193
+ const SEV_CLASS = { critical:"crit", warning:"warn", info:"info" };
194
+ const SEV_RU = { critical:"critical", warning:"warning", info:"info" };
195
+
196
+ const $ = s => document.querySelector(s);
197
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
198
+
199
+ const bandColor = pct => pct < 40 ? "var(--crit)" : pct < 70 ? "var(--warn)" : "var(--ok)";
200
+ const dimColor = s => s <= 2 ? "var(--crit)" : s === 3 ? "var(--warn)" : "var(--ok)";
201
+
202
+ function gauge(pct){
203
+ const r = 60, c = 2 * Math.PI * r, off = c * (1 - Math.max(0, Math.min(100, pct)) / 100);
204
+ const col = bandColor(pct);
205
+ return `<div class="gauge">
206
+ <svg width="138" height="138" viewBox="0 0 138 138">
207
+ <circle cx="69" cy="69" r="${r}" fill="none" stroke="#ece6d8" stroke-width="9"/>
208
+ <circle cx="69" cy="69" r="${r}" fill="none" stroke="${col}" stroke-width="9" stroke-linecap="round"
209
+ stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${c.toFixed(1)}" transform="rotate(-90 69 69)"
210
+ style="animation:dash 1s cubic-bezier(.22,1,.36,1) forwards"/>
211
+ <style>@keyframes dash{to{stroke-dashoffset:${off.toFixed(1)}}}</style>
212
+ </svg>
213
+ <div class="num"><b style="color:${col}">${pct}</b><span>of 100</span></div>
214
+ </div>`;
215
+ }
216
+
217
+ function renderAudit(d){
218
+ const ga = d.goalAlignment || {};
219
+ const dims = ga.dimensions || [];
220
+ const warns = (d.warnings || []).slice().sort((a,b)=>(SEV_ORDER[a.severity]??9)-(SEV_ORDER[b.severity]??9));
221
+ const counts = warns.reduce((m,w)=>(m[w.severity]=(m[w.severity]||0)+1,m),{});
222
+ const countStr = ["critical","warning","info"].filter(s=>counts[s]).map(s=>`${counts[s]} ${s}`).join(" · ");
223
+
224
+ let html = `<section class="sec"><div class="head">
225
+ ${gauge(Number(ga.overall)||0)}
226
+ <div class="lead">
227
+ <p class="verdict">${esc(ga.summary || "")}</p>
228
+ ${(ga.cappedBy||[]).length ? `<div class="capped"><span class="lbl">capped by:</span>${ga.cappedBy.map(c=>`<span class="tag crit mono">${esc(c)}</span>`).join("")}</div>`:""}
229
+ </div></div></section>`;
230
+
231
+ if (dims.length){
232
+ html += `<section class="sec"><div class="sec-h"><span>Goal dimensions</span><span class="wcount">score / 5</span></div>`;
233
+ html += dims.map(dm=>{
234
+ const s = Number(dm.score)||0, pct = (s/5)*100, col = dimColor(s);
235
+ return `<div class="dim">
236
+ <div class="name">${esc(DIM_LABELS[dm.key]||dm.key)}<span class="k">${esc(dm.key)}</span></div>
237
+ <div class="body">
238
+ <div class="bar"><div class="track"><div class="fill" style="width:${pct}%;background:${col}"></div></div><div class="sc">${s}/5</div></div>
239
+ <div class="rat">${esc(dm.rationale||"")}</div>
240
+ </div></div>`;
241
+ }).join("");
242
+ html += `</section>`;
243
+ }
244
+
245
+ if (warns.length){
246
+ html += `<section class="sec"><div class="sec-h"><span>Warnings</span><span class="wcount">${esc(countStr)}</span></div>`;
247
+ html += warns.map((w,i)=>{
248
+ const cl = SEV_CLASS[w.severity]||"info";
249
+ return `<div class="w ${cl}" style="--i:${i}">
250
+ <div class="row">
251
+ <span class="sev ${cl}">${esc(SEV_RU[w.severity]||w.severity)}</span>
252
+ <span class="code">${esc(w.code||"")}</span>
253
+ ${w.source?`<span class="src">${esc(w.source)}</span>`:""}
254
+ </div>
255
+ <div class="msg">${esc(w.message||"")}</div>
256
+ ${w.evidence?`<div class="ev">${esc(w.evidence)}</div>`:""}
257
+ </div>`;
258
+ }).join("");
259
+ html += `</section>`;
260
+ }
261
+
262
+ if ((d.rewriteHints||[]).length){
263
+ html += `<section class="sec"><div class="sec-h"><span>Rewrite hints</span></div>
264
+ <ol class="hints">${d.rewriteHints.map(h=>`<li>${esc(h)}</li>`).join("")}</ol></section>`;
265
+ }
266
+ return html;
267
+ }
268
+
269
+ function renderBrief(d){
270
+ const ok = d.status === "ok";
271
+ let html = `<section class="sec">
272
+ <span class="status ${ok?"ok":"clar"}"><span class="dot"></span>${ok?"Brief accepted — audit uses inferred fields":"Needs clarification"}</span>
273
+ <div class="cards2" style="margin-top:16px">
274
+ <div class="infcard"><div class="t">Goal (inferred)</div><div class="v">${esc(d.inferred?.goal||"—")}</div></div>
275
+ <div class="infcard"><div class="t">Audience (inferred)</div><div class="v">${esc(d.inferred?.audience||"—")}</div></div>
276
+ </div></section>`;
277
+ if ((d.gaps||[]).length){
278
+ html += `<section class="sec"><div class="sec-h"><span>Gaps</span></div>`;
279
+ html += d.gaps.map(g=>`<div class="gap">
280
+ <span class="fld">${esc(g.field)}</span>
281
+ <div class="reason">${esc(g.reason||"")}</div>
282
+ <div class="chips">${(g.candidates||[]).map(c=>`<span class="chip">${esc(c)}</span>`).join("")}</div>
283
+ </div>`).join("");
284
+ html += `</section>`;
285
+ }
286
+ return html;
287
+ }
288
+
289
+ function render(){
290
+ const err = $("#err"); err.style.display = "none";
291
+ let data;
292
+ try { data = JSON.parse($("#src").value); }
293
+ catch(e){ err.textContent = "Invalid JSON: " + e.message; err.style.display = "block"; $("#out").innerHTML=""; return; }
294
+ const out = $("#out");
295
+ if (data && data.goalAlignment) out.innerHTML = renderAudit(data);
296
+ else if (data && (data.status || data.gaps || data.inferred)) out.innerHTML = renderBrief(data);
297
+ else { err.textContent = "Not AuditReport or BriefCheck (missing goalAlignment / status)."; err.style.display="block"; out.innerHTML=""; }
298
+ }
299
+
300
+ function loadSample(which){
301
+ $("#src").value = $("#sample-"+which).textContent.trim();
302
+ render();
303
+ }
304
+
305
+ window.renderAuditPayload = function(data) {
306
+ $("#src").value = typeof data === "string" ? data : JSON.stringify(data, null, 2);
307
+ render();
308
+ };
309
+
310
+ $("#render").addEventListener("click", render);
311
+ $("#clear").addEventListener("click", ()=>{ $("#src").value=""; $("#out").innerHTML=""; $("#err").style.display="none"; });
312
+ document.querySelectorAll("[data-sample]").forEach(b=>b.addEventListener("click",()=>loadSample(b.dataset.sample)));
313
+
314
+ if (new URLSearchParams(location.search).get("embed") === "1") {
315
+ document.querySelector(".io").style.display = "none";
316
+ } else {
317
+ loadSample("audit");
318
+ }
319
+ </script>
320
+ </body>
321
+ </html>