kong commited on
Commit
de189a6
Β·
1 Parent(s): dae41b6

initial commit

Browse files
Files changed (3) hide show
  1. README.md +1 -2
  2. agent.py +245 -0
  3. app.py +135 -0
README.md CHANGED
@@ -10,5 +10,4 @@ pinned: false
10
  license: mit
11
  short_description: A AI powered Anki card generator
12
  ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
10
  license: mit
11
  short_description: A AI powered Anki card generator
12
  ---
13
+ # agent-demo-track
 
agent.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import mimetypes
5
+ import os
6
+ import re
7
+ import tempfile
8
+ import xml.etree.ElementTree as ET
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ import requests
13
+ from langgraph.graph import StateGraph, START, END
14
+ from typing_extensions import TypedDict
15
+
16
+ import anthropic
17
+ import dotenv
18
+ # Load environment variables from .env file
19
+ dotenv.load_dotenv()
20
+
21
+ # ----------------------------------------------------------------------------
22
+ # 1. State definition
23
+ # ----------------------------------------------------------------------------
24
+
25
+ class AnkiGeneratorState(TypedDict, total=False):
26
+ user_requirements: str # Extra user instructions / tags
27
+ card_types: str # Allowed card types (string)
28
+
29
+ # Exactly one of the following
30
+ pdf_file: Optional[Path]
31
+ img_file: Optional[Path]
32
+ url: Optional[str]
33
+
34
+ input_type: str # "pdf" | "image" | "url"
35
+
36
+ # Internal artifacts
37
+ model_response: str
38
+ result: Dict[str, Any]
39
+
40
+
41
+ # ----------------------------------------------------------------------------
42
+ # 2. Helpers
43
+ # ----------------------------------------------------------------------------
44
+
45
+ ANTHROPIC_MODEL = "claude-opus-4-20250514"
46
+ client = anthropic.Anthropic()
47
+
48
+
49
+ def _file_to_b64(p: Path) -> str:
50
+ return base64.b64encode(p.read_bytes()).decode()
51
+
52
+
53
+ def _url_fetch(url: str, timeout: int = 15) -> tuple[str, bytes]:
54
+ r = requests.get(url, timeout=timeout)
55
+ r.raise_for_status()
56
+ mime = r.headers.get("content-type", "application/octet-stream").split(";")[0]
57
+ return mime, r.content
58
+
59
+
60
+ def _join_text(msg) -> str:
61
+ if isinstance(msg.content, list):
62
+ return "\n".join(part.get("text", "") for part in msg.content if part.get("type") == "text")
63
+ return str(msg.content)
64
+
65
+
66
+ def _extract_xml(text: str) -> str:
67
+ m = re.search(r"<anki_cards[\s\S]*?</anki_cards>", text, re.I)
68
+ if not m:
69
+ raise ValueError("LLM output missing <anki_cards> block")
70
+ return m.group()
71
+
72
+
73
+ def _parse_cards(xml_str: str) -> list[dict]:
74
+ root = ET.fromstring(xml_str)
75
+ cards = []
76
+ for card in root.findall("card"):
77
+ cards.append({
78
+ "type": (card.findtext("type") or "").strip(),
79
+ "front": (card.findtext("front") or "").strip(),
80
+ "back": (card.findtext("back") or "").strip(),
81
+ })
82
+ return cards
83
+
84
+
85
+ def _prompt(src_kind: str, state: AnkiGeneratorState) -> str:
86
+ return (
87
+ f"""You are an AI assistant tasked with generating Anki cards from a {src_kind}.
88
+ Follow these rules:\n"
89
+ 1. Read the provided content.\n"
90
+ 2. Allowed card types: {state.get("card_types", "")}\n
91
+ 3. User notes: {state.get("user_requirements", "")}\n
92
+ 4. output your response as an XML block with <anki_cards> root element.\n"""
93
+ )
94
+
95
+ # ----------------------------------------------------------------------------
96
+ # 3. Node implementations
97
+ # ----------------------------------------------------------------------------
98
+
99
+ def get_input_type(state: AnkiGeneratorState) -> AnkiGeneratorState:
100
+ if state.get("pdf_file"):
101
+ state["input_type"] = "pdf"
102
+ elif state.get("img_file"):
103
+ state["input_type"] = "image"
104
+ elif state.get("url"):
105
+ state["input_type"] = "url"
106
+ else:
107
+ raise ValueError("Must supply pdf_file, img_file or url")
108
+ return state
109
+
110
+
111
+ def process_pdf(state: AnkiGeneratorState) -> AnkiGeneratorState:
112
+ pdf_b64 = _file_to_b64(state["pdf_file"])
113
+ message = client.messages.create(
114
+ model=ANTHROPIC_MODEL,
115
+ max_tokens=2048,
116
+ messages=[
117
+ {
118
+ "role": "user",
119
+ "content": [
120
+ {
121
+ "type": "document",
122
+ "source": {
123
+ "type": "base64",
124
+ "media_type": "application/pdf",
125
+ "data": pdf_b64,
126
+ },
127
+ },
128
+ {"type": "text", "text": _prompt("PDF", state)},
129
+ ],
130
+ }
131
+ ],
132
+ )
133
+ state["model_response"] = message.content[0].text
134
+ return state
135
+
136
+
137
+ def process_image(state: AnkiGeneratorState) -> AnkiGeneratorState:
138
+ img_b64 = _file_to_b64(state["img_file"])
139
+ mime = mimetypes.guess_type(state["img_file"])[0] or "image/png"
140
+ message = client.messages.create(
141
+ model=ANTHROPIC_MODEL,
142
+ max_tokens=2048,
143
+ messages=[
144
+ {
145
+ "role": "user",
146
+ "content": [
147
+ {
148
+ "type": "image",
149
+ "source": {"type": "base64", "media_type": mime, "data": img_b64},
150
+ },
151
+ {"type": "text", "text": _prompt("image", state)},
152
+ ],
153
+ }
154
+ ],
155
+ )
156
+ state["model_response"] = message.content[0].text
157
+ return state
158
+
159
+
160
+ def process_url(state: AnkiGeneratorState) -> AnkiGeneratorState:
161
+ mime, raw = _url_fetch(state["url"])
162
+ if mime == "application/pdf" or state["url"].lower().endswith(".pdf"):
163
+ tmp = Path(tempfile.mkstemp(suffix=".pdf")[1])
164
+ tmp.write_bytes(raw)
165
+ state["pdf_file"] = tmp
166
+ return process_pdf(state)
167
+ if mime.startswith("image/"):
168
+ ext = mimetypes.guess_extension(mime) or ".png"
169
+ tmp = Path(tempfile.mkstemp(suffix=ext)[1])
170
+ tmp.write_bytes(raw)
171
+ state["img_file"] = tmp
172
+ return process_image(state)
173
+ text = raw.decode("utf-8", errors="ignore")[:15000]
174
+ message = client.messages.create(
175
+ model=ANTHROPIC_MODEL,
176
+ max_tokens=1024,
177
+ messages=[
178
+ {"role": "user", "content": [{"type": "text", "text": text}, {"type": "text", "text": _prompt("webpage", state)}]},
179
+ ],
180
+ )
181
+ state["model_response"] = message.content[0].text
182
+ return state
183
+
184
+
185
+ def parse_and_generate(state: AnkiGeneratorState) -> AnkiGeneratorState:
186
+ print(state["model_response"])
187
+ xml_str = _extract_xml(state["model_response"])
188
+ cards = _parse_cards(xml_str)
189
+ if not cards:
190
+ raise ValueError("No cards extracted")
191
+ source = (
192
+ state.get("pdf_file") and state["pdf_file"].stem
193
+ ) or (
194
+ state.get("img_file") and state["img_file"].stem
195
+ ) or re.sub(r"\W+", "_", state.get("url", "source"))
196
+ state["result"] = {
197
+ "deck": {
198
+ "name": f"{source}_AnkiDeck",
199
+ "cards": cards,
200
+ "tags": [t.strip() for t in state.get("user_requirements", "").split(",") if t.strip()],
201
+ }
202
+ }
203
+ return state
204
+
205
+ # ----------------------------------------------------------------------------
206
+ # 4. Graph assembly
207
+ # ----------------------------------------------------------------------------
208
+
209
+ graph = StateGraph(AnkiGeneratorState)
210
+
211
+ for n, fn in [
212
+ ("get_input_type", get_input_type),
213
+ ("process_pdf", process_pdf),
214
+ ("process_image", process_image),
215
+ ("process_url", process_url),
216
+ ("parse_and_generate", parse_and_generate),
217
+ ]:
218
+ graph.add_node(n, fn)
219
+
220
+ # Conditional edges with single‑arg route func (current state only)
221
+ graph.add_edge(START, "get_input_type")
222
+
223
+ graph.add_conditional_edges(
224
+ "get_input_type",
225
+ lambda state: state["input_type"],
226
+ {"pdf": "process_pdf", "image": "process_image", "url": "process_url"},
227
+ )
228
+
229
+ for node in ["process_pdf", "process_image", "process_url"]:
230
+ graph.add_edge(node, "parse_and_generate")
231
+
232
+ graph.add_edge("parse_and_generate", END)
233
+
234
+ app_graph = graph.compile()
235
+
236
+ # ----------------------------------------------------------------------------
237
+ # 5. Public helper
238
+ # ----------------------------------------------------------------------------
239
+
240
+ def create_anki_deck(**kwargs) -> Dict[str, Any]:
241
+ state: AnkiGeneratorState = kwargs # type: ignore
242
+ final = app_graph.invoke(state)
243
+ return final["result"]
244
+
245
+
app.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ import random
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import gradio as gr
9
+ from agent import create_anki_deck
10
+
11
+ try:
12
+ import genanki # type: ignore
13
+
14
+ GENANKI_AVAILABLE = True
15
+ except ImportError:
16
+ GENANKI_AVAILABLE = False
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # generate .apkg or JSON)
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _build_package_file(deck_dict: dict, deck_name_override: str | None = None) -> str: # ← θΏ”ε›ž str
23
+ deck_title = (deck_name_override or deck_dict.get("name") or "AI_Deck").strip()
24
+
25
+ if GENANKI_AVAILABLE:
26
+ # ---------- generate .apkg ----------
27
+ import genanki
28
+ basic_model = genanki.Model(
29
+ 1607392319,
30
+ "AI Basic Model",
31
+ fields=[{"name": "Front"}, {"name": "Back"}],
32
+ templates=[{
33
+ "name": "Card 1",
34
+ "qfmt": "{{Front}}",
35
+ "afmt": "{{FrontSide}}<hr id=answer>{{Back}}",
36
+ }],
37
+ )
38
+ cloze_model = genanki.Model(
39
+ 1091735104,
40
+ "AI Cloze Model",
41
+ model_type=genanki.Model.CLOZE,
42
+ fields=[{"name": "Text"}, {"name": "Back"}],
43
+ templates=[{
44
+ "name": "Cloze Card",
45
+ "qfmt": "{{cloze:Text}}",
46
+ "afmt": "{{cloze:Text}}<br>{{Back}}",
47
+ }],
48
+ )
49
+ deck = genanki.Deck(random.getrandbits(32), deck_title)
50
+ tags = deck_dict.get("tags", [])
51
+ for card in deck_dict["cards"]:
52
+ model = cloze_model if card["type"].lower().startswith("cloze") else basic_model
53
+ note = genanki.Note(model=model, fields=[card["front"], card["back"]], tags=tags)
54
+ deck.add_note(note)
55
+ pkg = genanki.Package(deck)
56
+ tmpf = tempfile.NamedTemporaryFile(delete=False, suffix=".apkg")
57
+ pkg.write_to_file(tmpf.name)
58
+ return tmpf.name
59
+
60
+ # ---------- generate JSON ----------
61
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w", encoding="utf-8") as tf:
62
+ json.dump(deck_dict, tf, ensure_ascii=False, indent=2)
63
+ return tf.name
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # callback function to generate Anki deck
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def generate_deck(
70
+ uploaded_path: Optional[str],
71
+ url_input: str,
72
+ card_type_pref: str,
73
+ deck_name_pref: str,
74
+ tags_pref: str,
75
+ user_req_pref: str,
76
+ ):
77
+ if not uploaded_path and not url_input.strip():
78
+ raise gr.Error("Please upload a file or enter a URL.")
79
+
80
+ params: dict[str, object] = {
81
+ "card_types": card_type_pref,
82
+ "user_requirements": user_req_pref,
83
+ }
84
+
85
+ if uploaded_path:
86
+ path = Path(uploaded_path)
87
+ params["pdf_file" if path.suffix.lower() == ".pdf" else "img_file"] = path
88
+ else:
89
+ params["url"] = url_input.strip()
90
+
91
+ # agent
92
+ deck_dict = create_anki_deck(**params)["deck"]
93
+
94
+ if deck_name_pref.strip():
95
+ deck_dict["name"] = deck_name_pref.strip()
96
+ if tags_pref.strip():
97
+ deck_dict["tags"] = [t.strip() for t in tags_pref.split(",") if t.strip()]
98
+
99
+ output_path = _build_package_file(deck_dict, deck_name_pref)
100
+ return str(output_path)
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Gradio UI
104
+ # ---------------------------------------------------------------------------
105
+
106
+ demo = gr.Blocks(theme=gr.themes.Soft(), title="Anki Card Generator")
107
+
108
+ with demo:
109
+ gr.Markdown("# 🧠 Anki Card Generator")
110
+ gr.Markdown("Upload an image or PDF, or enter a URL to generate Anki cards.")
111
+
112
+ with gr.Row():
113
+ with gr.Column(scale=1):
114
+ file_input = gr.File(label="πŸ“ Upload PDF or image", file_types=["image", ".pdf"], type="filepath")
115
+ url_input = gr.Textbox(label="🌐 Or enter url", placeholder="https://example.com/article")
116
+ user_requirements_input = gr.Textbox(label="🎯 Your requirements", lines=4)
117
+ card_type_input = gr.Textbox(label="πŸ“‡ Card type", value="Basic,Cloze")
118
+ deck_name_input = gr.Textbox(label="🏷️ Deck name", value="Animal")
119
+ tags_input = gr.Textbox(label="✨ Tags (comma-separated)", placeholder="e.g. biology, mammals", value="biology,mammals")
120
+ generate_button = gr.Button("Generate", variant="primary")
121
+
122
+ with gr.Column(scale=1):
123
+ anki_output_file = gr.File(label="πŸ“₯ Download (.apkg / .json)")
124
+
125
+ generate_button.click(
126
+ fn=generate_deck,
127
+ inputs=[file_input, url_input, card_type_input, deck_name_input, tags_input, user_requirements_input],
128
+ outputs=[anki_output_file],
129
+ )
130
+
131
+ gr.Markdown("---")
132
+ gr.Markdown("βœ… genanki :{}".format("Available" if GENANKI_AVAILABLE else "Not installed (will export JSON)"))
133
+
134
+ if __name__ == "__main__":
135
+ demo.launch()