mukunda1729 commited on
Commit
9884451
·
verified ·
1 Parent(s): 073c12d

Upload 9 files

Browse files
Files changed (9) hide show
  1. .gitignore +16 -0
  2. LICENSE +202 -0
  3. README.md +105 -8
  4. app.py +222 -0
  5. config.py +62 -0
  6. digest.py +53 -0
  7. fetch.py +241 -0
  8. rank.py +201 -0
  9. requirements.txt +4 -0
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ .env.*
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .pytest_cache/
11
+ .DS_Store
12
+ .gradio/
13
+ gradio_cached_examples/
14
+ flagged/
15
+ state/
16
+ *.log
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md CHANGED
@@ -1,15 +1,112 @@
1
  ---
2
- title: Briefing 32
3
- emoji: 🏆
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: AI-news briefing the maker runs every 2 hours.
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: briefing-32
3
+ emoji: 📰
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.0.0
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ short_description: A 32B-class AI-news briefing the maker runs every 2 hours.
12
  ---
13
 
14
+ # briefing-32
15
+
16
+ A small-model AI-news briefing agent. Submission for the **Hugging Face
17
+ Build Small Hackathon** ([huggingface.co/build-small-hackathon](https://huggingface.co/build-small-hackathon))
18
+ in the **Backyard AI** track.
19
+
20
+ ## What it is
21
+
22
+ This is a deliberate down-port of [`ai-news-agent`](https://github.com/MukundaKatta/ai-news-agent),
23
+ a personal cron that already runs every two hours on the maker's laptop to
24
+ deliver an AI-news digest to WhatsApp. The production cron uses Groq
25
+ Llama-3.3-70B for relevance scoring. Build Small forces the same workflow
26
+ under 32B parameters.
27
+
28
+ The honest story for the Backyard AI track:
29
+
30
+ > "I have used a personal AI-news briefing every two hours since spring 2026.
31
+ > The original uses a 70B model on a free Groq tier. Build Small asked me to
32
+ > live under 32B, on a laptop. So I split the single 70B scoring pass into
33
+ > two cheaper passes on Qwen3-32B — a binary relevance filter, then a graded
34
+ > ranker — and the digest quality holds up."
35
+
36
+ ## Pipeline
37
+
38
+ ```
39
+ fetch (RSS · HN · arXiv · GitHub)
40
+
41
+
42
+ pass 1 — binary relevance filter on Qwen3-32B
43
+
44
+
45
+ pass 2 — graded 0–10 ranker on Qwen3-32B
46
+
47
+
48
+ digest renderer on Qwen3-32B
49
+ ```
50
+
51
+ Two small-model calls do the work one big-model call did before.
52
+
53
+ ## Sources (no Reddit / Bluesky)
54
+
55
+ - **RSS / Atom**: Anthropic, OpenAI, DeepMind, Google AI, Meta AI, Mistral,
56
+ xAI, HuggingFace, Latent Space, Import AI, The Rundown AI, Stratechery,
57
+ Simon Willison, Karpathy, Lilian Weng, Linus Lee, and several more
58
+ high-signal blogs and newsletters.
59
+ - **Hacker News**: AI-tagged stories via the Algolia public API.
60
+ - **arXiv**: newest `cs.AI` / `cs.CL` / `cs.LG` submissions.
61
+ - **GitHub**: repos with `topic:ai` created in the last 14 days, sorted by stars.
62
+
63
+ Reddit and Bluesky public endpoints both 403-block traffic in 2026, so the
64
+ port drops them. The production cron has the same scars in its logs.
65
+
66
+ ## Run locally
67
+
68
+ ```sh
69
+ pip install -r requirements.txt
70
+ HF_TOKEN=hf_xxx python app.py
71
+ ```
72
+
73
+ Then open the Gradio URL it prints. Click **Run briefing**.
74
+
75
+ ## Run as an HF Space
76
+
77
+ The repo is shaped like a standard Hugging Face Space. The `README.md`
78
+ front-matter wires `app.py` as the entry point and pins the Gradio SDK.
79
+ After deploy, the Space's "Settings → Variables and secrets" gets one
80
+ secret: `HF_TOKEN` (a read-permission token is plenty).
81
+
82
+ ## Model
83
+
84
+ Default model: **Qwen/Qwen3-32B** (Apache 2.0, 32B dense, native JSON mode),
85
+ routed through HF Inference Providers.
86
+
87
+ Alternatives that fit Build Small's ≤32B cap and were considered:
88
+ `Qwen/Qwen3-30B-A3B`, `deepseek-ai/DeepSeek-R1-Distill-Qwen-32B`,
89
+ `mistralai/Mistral-Small-24B-Instruct-2501`. Swap in the sidebar.
90
+
91
+ ## Targeted bonus quests
92
+
93
+ The hackathon has six optional bonus quests. This submission targets:
94
+
95
+ - **Field Notes** — a write-up about the 70B → 32B down-port and what
96
+ surprised me (see `docs/down-port-notes.md` after the build window).
97
+ - **Sharing is Caring** — a captured agent trace published alongside the
98
+ Space (see `docs/sample-trace.md`).
99
+ - **Off-Brand** — custom Gradio theme + layout (see `app.py`).
100
+
101
+ Optional stretch: **Llama Champion** (a llama.cpp variant for the same
102
+ pipeline) + **Off the Grid** (the llama.cpp variant doubles for that badge).
103
+
104
+ ## License
105
+
106
+ Apache 2.0.
107
+
108
+ ## Credit
109
+
110
+ Built by [Mukunda Katta](https://github.com/MukundaKatta) as an independent
111
+ project for Build Small. The production cron it down-ports is
112
+ [`MukundaKatta/ai-news-agent`](https://github.com/MukundaKatta/ai-news-agent).
app.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """briefing-32 — Gradio app entry for Hugging Face Spaces.
2
+
3
+ Build Small Hackathon submission (Backyard AI track):
4
+ A small-model down-port of ~/ai-news-agent. The production version uses
5
+ Groq Llama-3.3-70B; this version fits the same workflow under 32B params
6
+ using Qwen3-32B via Hugging Face Inference Providers.
7
+
8
+ Same pipeline as the every-2-hours cron the maker has running on a laptop:
9
+ fetch RSS / HN / arXiv / GitHub -> two-pass relevance filter + ranker ->
10
+ readable digest. Gradio is the delivery surface here instead of WhatsApp.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import time
16
+ from typing import Any
17
+
18
+ import gradio as gr
19
+ import pandas as pd
20
+
21
+ from config import (
22
+ DEFAULT_BASE_URL,
23
+ DEFAULT_MODEL,
24
+ MIN_NEW_ITEMS,
25
+ PER_SOURCE_CAP,
26
+ )
27
+ from digest import make_digest
28
+ from fetch import fetch_all
29
+ from rank import RankerConfig, rank_pipeline
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Core pipeline (callable from Gradio + scripts/cli.py)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def run_briefing(
38
+ window_hours: int,
39
+ enabled_sources: list[str],
40
+ model: str,
41
+ hf_token: str,
42
+ ) -> dict[str, Any]:
43
+ """Fetch -> filter -> rank -> digest. Returns everything for the UI."""
44
+ since_ts = time.time() - window_hours * 3600
45
+ enabled = set(enabled_sources) if enabled_sources else {"rss", "hn", "arxiv", "github"}
46
+
47
+ t0 = time.perf_counter()
48
+ raw = fetch_all(since_ts, enabled=enabled)
49
+ fetch_latency = time.perf_counter() - t0
50
+
51
+ cfg = RankerConfig(
52
+ base_url=DEFAULT_BASE_URL,
53
+ model=model or DEFAULT_MODEL,
54
+ api_key=hf_token or "",
55
+ )
56
+ result = rank_pipeline(raw, cfg=cfg)
57
+
58
+ digest = ""
59
+ if result.after_rank >= MIN_NEW_ITEMS:
60
+ digest = make_digest(result.items, cfg=cfg)
61
+ elif result.after_rank > 0:
62
+ digest = make_digest(result.items, cfg=cfg)
63
+
64
+ return {
65
+ "digest": digest or "_(no high-signal items in window)_",
66
+ "items": result.items,
67
+ "raw_count": result.raw_count,
68
+ "after_filter": result.after_filter,
69
+ "after_rank": result.after_rank,
70
+ "fetch_latency": fetch_latency,
71
+ "filter_latency": result.filter_latency,
72
+ "rank_latency": result.rank_latency,
73
+ "model": cfg.model,
74
+ }
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Gradio glue
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _items_to_df(items: list[dict]) -> pd.DataFrame:
83
+ if not items:
84
+ return pd.DataFrame(columns=["score", "source", "title", "reason", "url"])
85
+ rows = [
86
+ {
87
+ "score": it.get("score", 0),
88
+ "source": it.get("source", ""),
89
+ "title": it.get("title", ""),
90
+ "reason": it.get("reason", ""),
91
+ "url": it.get("url", ""),
92
+ }
93
+ for it in items
94
+ ]
95
+ return pd.DataFrame(rows)
96
+
97
+
98
+ def _stats_md(result: dict[str, Any]) -> str:
99
+ return (
100
+ f"**Model:** `{result['model']}` \n"
101
+ f"**Raw items fetched:** {result['raw_count']} \n"
102
+ f"**Survived filter:** {result['after_filter']} \n"
103
+ f"**Survived rank (score ≥ 6):** {result['after_rank']} \n"
104
+ f"**Fetch latency:** {result['fetch_latency']:.1f}s \n"
105
+ f"**Filter latency:** {result['filter_latency']:.1f}s \n"
106
+ f"**Rank latency:** {result['rank_latency']:.1f}s \n"
107
+ f"**Total LLM time:** {result['filter_latency'] + result['rank_latency']:.1f}s"
108
+ )
109
+
110
+
111
+ def _gradio_handler(window_hours, sources, model, hf_token):
112
+ try:
113
+ result = run_briefing(
114
+ window_hours=int(window_hours),
115
+ enabled_sources=list(sources or []),
116
+ model=(model or DEFAULT_MODEL).strip(),
117
+ hf_token=(hf_token or "").strip(),
118
+ )
119
+ except Exception as e:
120
+ return (
121
+ f"**Error:** `{e}`\n\nMake sure `HF_TOKEN` is set in Space secrets "
122
+ f"or pasted into the sidebar.",
123
+ pd.DataFrame(),
124
+ "_no run yet_",
125
+ )
126
+ return result["digest"], _items_to_df(result["items"]), _stats_md(result)
127
+
128
+
129
+ # Custom theme — "Off-Brand" bonus badge target.
130
+ THEME = gr.themes.Soft(
131
+ primary_hue="orange",
132
+ secondary_hue="slate",
133
+ neutral_hue="zinc",
134
+ ).set(
135
+ body_background_fill="#0b1220",
136
+ body_text_color="#e2e8f0",
137
+ block_background_fill="#111827",
138
+ block_border_width="1px",
139
+ block_border_color="#1f2937",
140
+ button_primary_background_fill="#f97316",
141
+ button_primary_text_color="#0b1220",
142
+ )
143
+
144
+
145
+ with gr.Blocks(theme=THEME, title="briefing-32 · Build Small entry") as demo:
146
+ gr.Markdown(
147
+ """
148
+ # briefing-32
149
+ **A 32B-class AI-news briefing the maker runs every 2 hours.**
150
+
151
+ Build Small Hackathon entry (Backyard AI track). Down-ported from the
152
+ production `ai-news-agent` cron (Groq Llama-3.3-70B → WhatsApp) onto
153
+ Qwen3-32B served by Hugging Face Inference Providers.
154
+
155
+ Pipeline: RSS + HN + arXiv + GitHub → cheap relevance filter →
156
+ graded 0–10 ranker → readable digest. Two open-weight model calls,
157
+ no 70B cloud round-trip required.
158
+ """
159
+ )
160
+
161
+ with gr.Row():
162
+ with gr.Column(scale=1):
163
+ gr.Markdown("### Controls")
164
+ window_hours = gr.Slider(
165
+ minimum=1, maximum=72, value=2, step=1,
166
+ label="Window (hours back)",
167
+ info="Production runs every 2hr — match that for the authentic story.",
168
+ )
169
+ sources = gr.CheckboxGroup(
170
+ choices=["rss", "hn", "arxiv", "github"],
171
+ value=["rss", "hn", "arxiv", "github"],
172
+ label="Sources",
173
+ )
174
+ model = gr.Textbox(
175
+ value=DEFAULT_MODEL,
176
+ label="Model (≤32B params)",
177
+ info="Default Qwen3-32B. Swap to Qwen3-30B-A3B for faster MoE inference.",
178
+ )
179
+ hf_token = gr.Textbox(
180
+ label="HF_TOKEN (optional — reads env if blank)",
181
+ placeholder="hf_…",
182
+ type="password",
183
+ )
184
+ run_btn = gr.Button("Run briefing", variant="primary")
185
+
186
+ gr.Markdown("### Run stats")
187
+ stats = gr.Markdown("_no run yet_")
188
+
189
+ with gr.Column(scale=2):
190
+ gr.Markdown("### Digest")
191
+ digest = gr.Markdown(
192
+ value="_Click **Run briefing** to fetch the last N hours of AI news, "
193
+ "rank it on a ≤32B model, and render a readable briefing._"
194
+ )
195
+ gr.Markdown("### Ranked items")
196
+ items_df = gr.Dataframe(
197
+ headers=["score", "source", "title", "reason", "url"],
198
+ value=pd.DataFrame(columns=["score", "source", "title", "reason", "url"]),
199
+ wrap=True,
200
+ interactive=False,
201
+ )
202
+
203
+ run_btn.click(
204
+ _gradio_handler,
205
+ inputs=[window_hours, sources, model, hf_token],
206
+ outputs=[digest, items_df, stats],
207
+ )
208
+
209
+ gr.Markdown(
210
+ """
211
+ ---
212
+ *Build Small Hackathon · Backyard AI track. Apache 2.0.*
213
+ Code: [github.com/MukundaKatta/briefing-32](https://github.com/MukundaKatta/briefing-32)
214
+ """
215
+ )
216
+
217
+
218
+ if __name__ == "__main__":
219
+ demo.queue(max_size=8).launch(
220
+ server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
221
+ server_port=int(os.environ.get("PORT", "7860")),
222
+ )
config.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Config — model defaults, source list, tunables.
2
+
3
+ Build Small Hackathon constraints: model must be ≤32B params and runnable on
4
+ a laptop. Default is Qwen3-32B routed through HF Inference Providers so the
5
+ HF Space talks to a real open-weight model with predictable cost.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ # Default model — Apache 2.0, 32B dense, native JSON mode.
12
+ DEFAULT_MODEL = os.getenv("BRIEFING_MODEL", "Qwen/Qwen3-32B")
13
+
14
+ # HF Inference Providers OpenAI-compatible router.
15
+ DEFAULT_BASE_URL = os.getenv("BRIEFING_BASE_URL", "https://router.huggingface.co/v1")
16
+
17
+ # Smart-batch threshold for the digest section. Below this, the UI says
18
+ # "nothing high-signal in the window" rather than rendering noise.
19
+ MIN_NEW_ITEMS = int(os.getenv("MIN_NEW_ITEMS", "3"))
20
+
21
+ # Per-source cap to bound prompt size.
22
+ PER_SOURCE_CAP = int(os.getenv("PER_SOURCE_CAP", "20"))
23
+
24
+ # Minimum relevance score (0-10) to make it into the digest.
25
+ MIN_RELEVANCE = int(os.getenv("MIN_RELEVANCE", "6"))
26
+
27
+ # Top-N items to put into the digest prompt after ranking.
28
+ DIGEST_TOP_N = int(os.getenv("DIGEST_TOP_N", "12"))
29
+
30
+ # ArXiv categories pulled live.
31
+ ARXIV_CATEGORIES = ["cs.AI", "cs.CL", "cs.LG"]
32
+
33
+ # GitHub trending topic filter.
34
+ GITHUB_TRENDING_TOPIC = "ai"
35
+
36
+ # RSS feeds — lab blogs + high-signal newsletters + YouTube channels.
37
+ RSS_FEEDS: list[tuple[str, str]] = [
38
+ # AI labs
39
+ ("Anthropic", "https://www.anthropic.com/news/rss.xml"),
40
+ ("OpenAI", "https://openai.com/news/rss.xml"),
41
+ ("Google DeepMind", "https://deepmind.google/blog/rss.xml"),
42
+ ("Google AI", "https://blog.google/technology/ai/rss/"),
43
+ ("Meta AI", "https://ai.meta.com/blog/rss/"),
44
+ ("Mistral", "https://mistral.ai/news/feed.xml"),
45
+ ("xAI", "https://x.ai/blog/rss.xml"),
46
+ ("HuggingFace", "https://huggingface.co/blog/feed.xml"),
47
+ # Newsletters / blogs
48
+ ("Latent Space", "https://www.latent.space/feed"),
49
+ ("Import AI", "https://importai.substack.com/feed"),
50
+ ("The Rundown AI", "https://www.therundown.ai/feed"),
51
+ ("Stratechery", "https://stratechery.com/feed/"),
52
+ ("Simon Willison", "https://simonwillison.net/atom/everything/"),
53
+ ("Andrej Karpathy", "https://karpathy.github.io/feed.xml"),
54
+ ("One Useful Thing", "https://www.oneusefulthing.org/feed"),
55
+ ("AI Snake Oil", "https://www.aisnakeoil.com/feed"),
56
+ ("Last Week in AI", "https://lastweekin.ai/feed"),
57
+ ("AI Tidbits", "https://aitidbits.substack.com/feed"),
58
+ ("Linus Lee", "https://thesephist.com/posts.xml"),
59
+ ("Lilian Weng", "https://lilianweng.github.io/index.xml"),
60
+ # YouTube (Atom feeds, no key required)
61
+ ("YT: Yannic Kilcher", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZHmQk67mSJgfCCTn7xBfew"),
62
+ ]
digest.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Digest renderer — turns top-N ranked items into a readable briefing."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from config import DIGEST_TOP_N
7
+ from rank import RankerConfig, _chat
8
+
9
+
10
+ _DIGEST_SYSTEM = "You write tight, useful AI-news briefings. No fluff."
11
+
12
+
13
+ _DIGEST_PROMPT = """Write a 2-hour AI-news briefing from the items below.
14
+
15
+ RULES:
16
+ - Group by theme if obvious (Models / Research / Tools / Industry); otherwise a flat list.
17
+ - Each item: 1-2 lines in plain English. End the item with the URL on its own line.
18
+ - Lead with WHAT CHANGED and WHY IT MATTERS — not the source name.
19
+ - No markdown headers, no bold asterisks. Optional bullet (•).
20
+ - Skip items that are obvious duplicates or hype with no concrete new info.
21
+ - Close with a one-line meta note ("3 from labs, 2 from research, 1 from tools" style).
22
+ - Target ~1500 chars total. Stay short. Skip filler.
23
+
24
+ Items (ranked by importance, highest first):
25
+ {items_json}
26
+ """
27
+
28
+
29
+ def make_digest(ranked: list[dict], cfg: RankerConfig | None = None) -> str:
30
+ """Render the top-N ranked items as a readable briefing."""
31
+ if not ranked:
32
+ return "_(no high-signal items in window)_"
33
+ cfg = cfg or RankerConfig()
34
+ top = ranked[:DIGEST_TOP_N]
35
+ indexed = [
36
+ {
37
+ "source": it.get("source", ""),
38
+ "title": (it.get("title") or "")[:200],
39
+ "url": it.get("url", ""),
40
+ "summary": (it.get("summary") or "")[:300],
41
+ "score": it.get("score", 5),
42
+ "reason": it.get("reason", ""),
43
+ }
44
+ for it in top
45
+ ]
46
+ return _chat(
47
+ cfg,
48
+ _DIGEST_SYSTEM,
49
+ _DIGEST_PROMPT.format(items_json=json.dumps(indexed, ensure_ascii=False, indent=2)),
50
+ json_mode=False,
51
+ temperature=0.3,
52
+ max_tokens=2000,
53
+ ).strip()
fetch.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetchers — RSS, Hacker News, ArXiv, GitHub.
2
+
3
+ All return a uniform `Item` shape so the ranker doesn't care about origin:
4
+ {source, title, url, summary, published_ts}
5
+
6
+ Ported from `~/ai-news-agent/sources/` with two changes:
7
+ 1. No external config.py import — everything lives in briefing.config
8
+ 2. Reddit + Bluesky removed (both 403-block public traffic in 2026)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import time
14
+ from datetime import datetime, timedelta, timezone
15
+ from typing import Iterable
16
+ from xml.etree import ElementTree as ET
17
+
18
+ import feedparser
19
+ import httpx
20
+
21
+ from config import (
22
+ ARXIV_CATEGORIES,
23
+ GITHUB_TRENDING_TOPIC,
24
+ PER_SOURCE_CAP,
25
+ RSS_FEEDS,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # RSS / Atom
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def fetch_rss(since_ts: float, feeds: Iterable[tuple[str, str]] = RSS_FEEDS) -> list[dict]:
35
+ items: list[dict] = []
36
+ for label, url in feeds:
37
+ try:
38
+ feed = feedparser.parse(url)
39
+ except Exception as e:
40
+ print(f"[rss] {label} failed: {e}")
41
+ continue
42
+ for entry in feed.entries[:PER_SOURCE_CAP]:
43
+ published = _entry_time(entry)
44
+ if published and published < since_ts:
45
+ continue
46
+ items.append(
47
+ {
48
+ "source": f"rss:{label}",
49
+ "title": (entry.get("title") or "").strip(),
50
+ "url": entry.get("link") or "",
51
+ "summary": (entry.get("summary") or "")[:500],
52
+ "published_ts": published or time.time(),
53
+ }
54
+ )
55
+ return items
56
+
57
+
58
+ def _entry_time(entry) -> float | None:
59
+ for key in ("published_parsed", "updated_parsed"):
60
+ t = entry.get(key)
61
+ if t:
62
+ return time.mktime(t)
63
+ return None
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Hacker News via Algolia (no key)
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ _ALGOLIA = "https://hn.algolia.com/api/v1/search_by_date"
72
+ _HN_TERMS = ["AI", "LLM", "Anthropic", "OpenAI", "Claude", "Gemini", "Llama", "agent"]
73
+
74
+
75
+ def fetch_hn(since_ts: float) -> list[dict]:
76
+ items: list[dict] = []
77
+ seen: set[int] = set()
78
+ cutoff = int(since_ts)
79
+ with httpx.Client(timeout=15) as client:
80
+ for term in _HN_TERMS:
81
+ try:
82
+ r = client.get(
83
+ _ALGOLIA,
84
+ params={
85
+ "query": term,
86
+ "tags": "story",
87
+ "numericFilters": f"created_at_i>{cutoff},points>10",
88
+ "hitsPerPage": PER_SOURCE_CAP,
89
+ },
90
+ )
91
+ r.raise_for_status()
92
+ for hit in r.json().get("hits", []):
93
+ obj_id = hit.get("objectID")
94
+ if obj_id in seen:
95
+ continue
96
+ seen.add(obj_id)
97
+ items.append(
98
+ {
99
+ "source": "hn",
100
+ "title": hit.get("title") or hit.get("story_title") or "",
101
+ "url": hit.get("url")
102
+ or f"https://news.ycombinator.com/item?id={obj_id}",
103
+ "summary": f"{hit.get('points', 0)} pts, "
104
+ f"{hit.get('num_comments', 0)} comments",
105
+ "published_ts": hit.get("created_at_i") or time.time(),
106
+ }
107
+ )
108
+ except Exception as e:
109
+ print(f"[hn] term={term} failed: {e}")
110
+ return items
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # ArXiv
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ _NS = {"a": "http://www.w3.org/2005/Atom"}
119
+
120
+
121
+ def fetch_arxiv(since_ts: float) -> list[dict]:
122
+ items: list[dict] = []
123
+ cat_query = " OR ".join(f"cat:{c}" for c in ARXIV_CATEGORIES)
124
+ with httpx.Client(timeout=20) as client:
125
+ try:
126
+ r = client.get(
127
+ "https://export.arxiv.org/api/query",
128
+ params={
129
+ "search_query": cat_query,
130
+ "sortBy": "submittedDate",
131
+ "sortOrder": "descending",
132
+ "max_results": PER_SOURCE_CAP,
133
+ },
134
+ )
135
+ r.raise_for_status()
136
+ root = ET.fromstring(r.text)
137
+ for entry in root.findall("a:entry", _NS):
138
+ title = (entry.findtext("a:title", default="", namespaces=_NS) or "").strip()
139
+ summary = (entry.findtext("a:summary", default="", namespaces=_NS) or "").strip()
140
+ published = entry.findtext("a:published", default="", namespaces=_NS) or ""
141
+ link_el = entry.find("a:link[@rel='alternate']", _NS)
142
+ url = link_el.get("href") if link_el is not None else ""
143
+ ts = _iso_ts(published)
144
+ if ts < since_ts:
145
+ continue
146
+ items.append(
147
+ {
148
+ "source": "arxiv",
149
+ "title": title.replace("\n", " "),
150
+ "url": url,
151
+ "summary": summary[:500].replace("\n", " "),
152
+ "published_ts": ts or time.time(),
153
+ }
154
+ )
155
+ except Exception as e:
156
+ print(f"[arxiv] failed: {e}")
157
+ return items
158
+
159
+
160
+ def _iso_ts(s: str) -> float:
161
+ try:
162
+ return time.mktime(time.strptime(s[:19], "%Y-%m-%dT%H:%M:%S"))
163
+ except Exception:
164
+ return 0.0
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # GitHub trending (topic:ai)
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ _GH = "https://api.github.com"
173
+
174
+
175
+ def fetch_github(since_ts: float) -> list[dict]:
176
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=14)).strftime("%Y-%m-%d")
177
+ headers = {"Accept": "application/vnd.github+json"}
178
+ if os.environ.get("GITHUB_TOKEN"):
179
+ headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"
180
+ items: list[dict] = []
181
+ with httpx.Client(timeout=15, headers=headers) as client:
182
+ try:
183
+ r = client.get(
184
+ f"{_GH}/search/repositories",
185
+ params={
186
+ "q": f"topic:{GITHUB_TRENDING_TOPIC} created:>{cutoff}",
187
+ "sort": "stars",
188
+ "order": "desc",
189
+ "per_page": PER_SOURCE_CAP,
190
+ },
191
+ )
192
+ r.raise_for_status()
193
+ for repo in r.json().get("items", []):
194
+ ts = _iso_ts(repo.get("pushed_at", ""))
195
+ if ts < since_ts:
196
+ continue
197
+ items.append(
198
+ {
199
+ "source": "github",
200
+ "title": f"{repo['full_name']} — "
201
+ f"{repo.get('description') or ''}".strip(),
202
+ "url": repo["html_url"],
203
+ "summary": f"{repo.get('stargazers_count', 0)} stars, "
204
+ f"language={repo.get('language', '?')}",
205
+ "published_ts": ts or time.time(),
206
+ }
207
+ )
208
+ except Exception as e:
209
+ print(f"[github] failed: {e}")
210
+ return items
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Aggregate
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ def fetch_all(since_ts: float, *, enabled: set[str] | None = None) -> list[dict]:
219
+ """Run every enabled fetcher. `enabled` is a set like {'rss', 'hn'}.
220
+
221
+ `None` means run all. Returns a flat list of Items.
222
+ """
223
+ fetchers: dict[str, callable] = {
224
+ "rss": fetch_rss,
225
+ "hn": fetch_hn,
226
+ "arxiv": fetch_arxiv,
227
+ "github": fetch_github,
228
+ }
229
+ if enabled is None:
230
+ enabled = set(fetchers.keys())
231
+ out: list[dict] = []
232
+ for name, fn in fetchers.items():
233
+ if name not in enabled:
234
+ continue
235
+ try:
236
+ chunk = fn(since_ts)
237
+ print(f"[fetch] {name}: {len(chunk)} items")
238
+ out.extend(chunk)
239
+ except Exception as e:
240
+ print(f"[fetch] {name} crashed: {e}")
241
+ return out
rank.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-pass ranker on a ≤32B open-weight model via HF Inference Providers.
2
+
3
+ Pass 1: cheap relevance filter — for each item, "is this AI news worth a
4
+ senior engineer's two minutes?" Yes/no.
5
+ Pass 2: structured 0-10 ranking on the survivors. Surfaces the top items.
6
+
7
+ The down-port story for Build Small: the production ai-news-agent runs a
8
+ single 70B-Groq scoring pass over the full batch. That works but it spends
9
+ 70B-class budget on items that are obviously noise (HN posts about
10
+ non-AI scams that hit the AI keyword set). At 32B we split the work — a
11
+ cheap binary filter first to drop obvious junk, then a graded score on the
12
+ real candidates. Same end signal, half the prompt tokens at the expensive
13
+ step.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import time
20
+ from dataclasses import dataclass
21
+
22
+ import httpx
23
+
24
+ from config import DEFAULT_BASE_URL, DEFAULT_MODEL, MIN_RELEVANCE
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Provider client
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @dataclass
33
+ class RankerConfig:
34
+ base_url: str = DEFAULT_BASE_URL
35
+ model: str = DEFAULT_MODEL
36
+ api_key: str = "" # populated from HF_TOKEN at call time if blank
37
+ timeout: float = 90.0
38
+
39
+
40
+ def _client(cfg: RankerConfig) -> httpx.Client:
41
+ api_key = cfg.api_key or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN", "")
42
+ if not api_key:
43
+ raise RuntimeError(
44
+ "HF_TOKEN missing — set it in the environment or pass api_key= explicitly."
45
+ )
46
+ return httpx.Client(
47
+ base_url=cfg.base_url,
48
+ timeout=cfg.timeout,
49
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
50
+ )
51
+
52
+
53
+ def _chat(cfg: RankerConfig, system: str, user: str, *, json_mode: bool = True,
54
+ temperature: float = 0.2, max_tokens: int = 4000) -> str:
55
+ payload = {
56
+ "model": cfg.model,
57
+ "messages": [
58
+ {"role": "system", "content": system},
59
+ {"role": "user", "content": user},
60
+ ],
61
+ "temperature": temperature,
62
+ "max_tokens": max_tokens,
63
+ }
64
+ if json_mode:
65
+ payload["response_format"] = {"type": "json_object"}
66
+ with _client(cfg) as cli:
67
+ r = cli.post("/chat/completions", json=payload)
68
+ r.raise_for_status()
69
+ return r.json()["choices"][0]["message"]["content"]
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Pass 1 — binary relevance filter
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ _FILTER_SYSTEM = "You are a precise JSON-only classifier. No prose."
78
+
79
+
80
+ _FILTER_PROMPT = """You are pre-filtering items for a 2-hour AI-news briefing for a senior AI engineer.
81
+
82
+ Mark each item KEEP if it is AI/ML news that a senior engineer would care about (model releases, capability shifts, key research, important industry moves, notable benchmarks, infrastructure changes). Mark DROP if it is noise, off-topic, hype-with-no-substance, repeat news from earlier today, or non-AI items.
83
+
84
+ Return JSON only:
85
+ {{"verdicts": [{{"i": 0, "v": "KEEP"}}, {{"i": 1, "v": "DROP"}}, ...]}}
86
+
87
+ Items:
88
+ {items_json}
89
+ """
90
+
91
+
92
+ def filter_relevant(items: list[dict], cfg: RankerConfig | None = None) -> list[dict]:
93
+ """Pass 1 — drop obvious noise. Returns items that survived."""
94
+ if not items:
95
+ return []
96
+ cfg = cfg or RankerConfig()
97
+ indexed = [
98
+ {"i": i, "source": it.get("source", ""), "title": (it.get("title") or "")[:200]}
99
+ for i, it in enumerate(items)
100
+ ]
101
+ raw = _chat(
102
+ cfg,
103
+ _FILTER_SYSTEM,
104
+ _FILTER_PROMPT.format(items_json=json.dumps(indexed, ensure_ascii=False)),
105
+ )
106
+ try:
107
+ data = json.loads(raw)
108
+ keep = {entry["i"] for entry in data.get("verdicts", []) if entry.get("v") == "KEEP"}
109
+ except Exception as e:
110
+ print(f"[filter] parse failed, keeping all: {e}")
111
+ keep = set(range(len(items)))
112
+ return [items[i] for i in range(len(items)) if i in keep]
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Pass 2 — graded ranker
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ _RANKER_SYSTEM = "You are a precise JSON-only scorer. No prose."
121
+
122
+
123
+ _RANKER_PROMPT = """You are an AI-news editor scoring items for a 2-hour briefing for a senior AI engineer.
124
+
125
+ Score each item 0-10 on importance and novelty. High scores (8-10) = major model releases, significant research breakthroughs, capability shifts, key industry moves, notable benchmarks. Medium (5-7) = relevant but smaller updates, useful tools, interesting research. Low (0-4) = noise, hype with no substance, repackaged news, off-topic.
126
+
127
+ Return JSON only:
128
+ {{"scores": [{{"i": 0, "score": 8, "reason": "short why"}}, ...]}}
129
+
130
+ Items:
131
+ {items_json}
132
+ """
133
+
134
+
135
+ def rank_items(items: list[dict], cfg: RankerConfig | None = None) -> list[dict]:
136
+ """Pass 2 — graded score 0-10. Items below MIN_RELEVANCE are dropped.
137
+
138
+ Returns sorted descending by score, each item gets a `score` and
139
+ `reason` field added.
140
+ """
141
+ if not items:
142
+ return []
143
+ cfg = cfg or RankerConfig()
144
+ indexed = [
145
+ {"i": i, "source": it.get("source", ""), "title": (it.get("title") or "")[:200]}
146
+ for i, it in enumerate(items)
147
+ ]
148
+ raw = _chat(
149
+ cfg,
150
+ _RANKER_SYSTEM,
151
+ _RANKER_PROMPT.format(items_json=json.dumps(indexed, ensure_ascii=False)),
152
+ )
153
+ try:
154
+ data = json.loads(raw)
155
+ score_map = {entry["i"]: (int(entry["score"]), entry.get("reason", ""))
156
+ for entry in data.get("scores", [])}
157
+ except Exception as e:
158
+ print(f"[rank] parse failed, defaulting all to 5: {e}")
159
+ score_map = {i: (5, "parse error") for i in range(len(items))}
160
+
161
+ out: list[dict] = []
162
+ for i, item in enumerate(items):
163
+ score, reason = score_map.get(i, (5, ""))
164
+ if score < MIN_RELEVANCE:
165
+ continue
166
+ out.append({**item, "score": score, "reason": reason})
167
+ out.sort(key=lambda x: x["score"], reverse=True)
168
+ return out
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Combined pipeline
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ @dataclass
177
+ class RankResult:
178
+ raw_count: int
179
+ after_filter: int
180
+ after_rank: int
181
+ items: list[dict]
182
+ filter_latency: float
183
+ rank_latency: float
184
+
185
+
186
+ def rank_pipeline(items: list[dict], cfg: RankerConfig | None = None) -> RankResult:
187
+ """Filter then rank. Returns the surviving items plus per-stage latency."""
188
+ cfg = cfg or RankerConfig()
189
+ t0 = time.perf_counter()
190
+ filtered = filter_relevant(items, cfg)
191
+ t1 = time.perf_counter()
192
+ ranked = rank_items(filtered, cfg)
193
+ t2 = time.perf_counter()
194
+ return RankResult(
195
+ raw_count= len(items),
196
+ after_filter= len(filtered),
197
+ after_rank= len(ranked),
198
+ items= ranked,
199
+ filter_latency= t1 - t0,
200
+ rank_latency= t2 - t1,
201
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ httpx>=0.27
3
+ feedparser>=6.0.11
4
+ pandas>=2.2