File size: 4,584 Bytes
f6a6455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""Stage 7: transform the timestamped transcript into a structured tutorial.

Uses an HF Inference Providers chat model (default DeepSeek-V3) billed to the user's
token. The model is asked for strict JSON so the downstream weighted indicator and the
.docx builder have stable fields to work with.
"""
from __future__ import annotations

import json
import re

from huggingface_hub import InferenceClient

DEFAULT_LLM = "deepseek-ai/DeepSeek-V3"

# Rough char budget to stay clear of context limits on the free path. Long transcripts
# are truncated (with a marker); good enough for a tutorial summary.
MAX_TRANSCRIPT_CHARS = 24000

_SYSTEM = (
    "You are a technical writer. You convert a timestamped video transcript into a clear, "
    "step-by-step written tutorial. You ALWAYS respond with a single JSON object and no "
    "prose outside it."
)

_INSTRUCTIONS = """\
Turn the transcript below into a tutorial. Return ONLY a JSON object with this schema:

{
  "title": "string - concise tutorial title",
  "intro": "string - 2-4 sentence overview",
  "steps": [
    {
      "heading": "string - short step title",
      "body": "string - 1-3 paragraphs explaining this step in your own words",
      "quote": "string - a short, near-verbatim snippet (<=120 chars) copied from the "
               "transcript line this step is based on, used to locate the moment",
      "t_llm": number,        // best timestamp IN SECONDS for an illustrative screenshot
      "importance": number    // 0..1, how worth screenshotting this step is
    }
  ]
}

Rules:
- 4 to 10 steps. Keep quotes copied from the transcript so they can be matched back.
- t_llm must be within the transcript's time range.
- Output valid JSON only. No markdown, no comments in the actual output.

Transcript (each line is "[mm:ss] text"):
---
{transcript}
---
"""


def _extract_json(text: str) -> dict:
    """Parse the model output into a dict, tolerating code fences / stray prose."""
    text = text.strip()
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.DOTALL).strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        start, end = text.find("{"), text.rfind("}")
        if start != -1 and end != -1 and end > start:
            return json.loads(text[start:end + 1])
        raise


def _normalize(data: dict) -> dict:
    """Coerce/clean fields so downstream stages never crash on missing keys."""
    steps = []
    for s in data.get("steps", []) or []:
        try:
            t = float(s.get("t_llm", 0) or 0)
        except (TypeError, ValueError):
            t = 0.0
        try:
            imp = float(s.get("importance", 0.5) or 0.5)
        except (TypeError, ValueError):
            imp = 0.5
        steps.append({
            "heading": str(s.get("heading", "Step")).strip() or "Step",
            "body": str(s.get("body", "")).strip(),
            "quote": str(s.get("quote", "")).strip(),
            "t_llm": max(0.0, t),
            "importance": min(1.0, max(0.0, imp)),
        })
    if not steps:
        raise RuntimeError("The LLM returned no usable steps.")
    return {
        "title": str(data.get("title", "Tutorial")).strip() or "Tutorial",
        "intro": str(data.get("intro", "")).strip(),
        "steps": steps,
    }


def generate_tutorial(transcript: str, hf_token: str, model: str = DEFAULT_LLM) -> dict:
    """Call the chat model and return a normalized ``{title, intro, steps}`` dict."""
    if not hf_token:
        raise ValueError("An HF token is required for the tutorial LLM (billed to your key).")

    truncated = transcript[:MAX_TRANSCRIPT_CHARS]
    if len(transcript) > MAX_TRANSCRIPT_CHARS:
        truncated += "\n[... transcript truncated for length ...]"
    prompt = _INSTRUCTIONS.replace("{transcript}", truncated)

    client = InferenceClient(token=hf_token)
    try:
        resp = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": _SYSTEM},
                {"role": "user", "content": prompt},
            ],
            temperature=0.3,
            max_tokens=4000,
        )
    except Exception as exc:
        raise RuntimeError(f"Tutorial LLM call failed ({model}): {exc}") from exc

    content = resp.choices[0].message.content or ""
    try:
        data = _extract_json(content)
    except Exception as exc:
        raise RuntimeError(
            f"Could not parse JSON from the LLM. First 400 chars:\n{content[:400]}"
        ) from exc
    return _normalize(data)