TomLii commited on
Commit
54c79d6
·
0 Parent(s):

Initialize Hugging Face Space starter

Browse files
Files changed (5) hide show
  1. .env.example +5 -0
  2. .gitignore +16 -0
  3. README.md +57 -0
  4. app.py +292 -0
  5. requirements.txt +5 -0
.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Optional. For Hugging Face Inference API (free quota with your HF account).
2
+ HF_TOKEN=hf_xxx
3
+
4
+ # Default model shown in UI.
5
+ DEFAULT_MODEL=Qwen/Qwen2.5-7B-Instruct
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .Python
6
+ .venv/
7
+ venv/
8
+
9
+ # IDE
10
+ .idea/
11
+ .vscode/
12
+
13
+ # Logs / local files
14
+ *.log
15
+ .env
16
+ .DS_Store
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DeepResearch Space Starter
3
+ emoji: 🔎
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.29.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # DeepResearch Space Starter
13
+
14
+ A standalone Hugging Face Space starter for a DeepResearch-style agent.
15
+
16
+ It supports:
17
+ - multi-turn reasoning loop
18
+ - `search` tool (DuckDuckGo)
19
+ - `visit` tool (webpage fetch + text extraction)
20
+ - final answer in `<answer>...</answer>`
21
+ - easy model replacement later
22
+
23
+ ## 1) Quick Start (Local)
24
+
25
+ ```bash
26
+ python -m venv .venv
27
+ source .venv/bin/activate
28
+ pip install -r requirements.txt
29
+ python app.py
30
+ ```
31
+
32
+ ## 2) Deploy to Hugging Face Space
33
+
34
+ 1. Create a new Space (SDK = **Gradio**).
35
+ 2. Push this repository to the Space repository.
36
+ 3. In Space **Settings -> Secrets**, add:
37
+ - `HF_TOKEN` (recommended for stable free inference access)
38
+ 4. Optional Variables:
39
+ - `DEFAULT_MODEL` (default: `Qwen/Qwen2.5-7B-Instruct`)
40
+
41
+ ## 3) Free Model First, Your Model Later
42
+
43
+ You can start with a free inference model, then switch by changing only env/config:
44
+
45
+ - Current: `DEFAULT_MODEL=Qwen/Qwen2.5-7B-Instruct`
46
+ - Later: set your own model name or API-compatible endpoint logic in `app.py` (`call_model` function).
47
+
48
+ Recommended migration strategy:
49
+ 1. keep tool protocol unchanged (`<tool_call>`, `<tool_response>`, `<answer>`)
50
+ 2. replace only model adapter (`call_model`)
51
+ 3. keep UI and tool chain unchanged
52
+
53
+ ## 4) Notes
54
+
55
+ - This is a lightweight starter, not a full production benchmark runner.
56
+ - Web fetching quality depends on target website anti-bot rules and page structure.
57
+ - For stronger reliability, add retry/backoff and persistent tool cache.
app.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ import gradio as gr
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+ from duckduckgo_search import DDGS
11
+ from huggingface_hub import InferenceClient
12
+
13
+
14
+ DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "Qwen/Qwen2.5-7B-Instruct")
15
+
16
+ SYSTEM_PROMPT = """You are a Deep Research assistant.
17
+ You can think step by step, use tools, and then return a final answer.
18
+
19
+ Tool protocol:
20
+ - To call a tool, output exactly one block:
21
+ <tool_call>
22
+ {"name":"search","arguments":{"query":"...","max_results":5}}
23
+ </tool_call>
24
+ or
25
+ <tool_call>
26
+ {"name":"visit","arguments":{"url":"...","max_chars":6000}}
27
+ </tool_call>
28
+
29
+ - When you are done, output:
30
+ <answer>
31
+ ...final answer...
32
+ </answer>
33
+
34
+ Rules:
35
+ - Use tools when needed, but avoid repeated calls to the same URL/query.
36
+ - Cite useful URLs in your final answer.
37
+ - If a tool fails, recover and continue.
38
+ """
39
+
40
+
41
+ TOOL_RESPONSE_TEMPLATE = """<tool_response>
42
+ {payload}
43
+ </tool_response>"""
44
+
45
+
46
+ @dataclass
47
+ class AgentState:
48
+ searched_queries: List[str] = field(default_factory=list)
49
+ visited_urls: List[str] = field(default_factory=list)
50
+ trace: List[Dict[str, Any]] = field(default_factory=list)
51
+
52
+
53
+ def extract_answer(text: str) -> Optional[str]:
54
+ match = re.search(r"<answer>\s*(.*?)\s*</answer>", text, flags=re.DOTALL | re.IGNORECASE)
55
+ return match.group(1).strip() if match else None
56
+
57
+
58
+ def parse_tool_call(text: str) -> Tuple[Optional[str], Optional[Dict[str, Any]], Optional[str]]:
59
+ match = re.search(r"<tool_call>\s*(.*?)\s*</tool_call>", text, flags=re.DOTALL | re.IGNORECASE)
60
+ if not match:
61
+ return None, None, None
62
+ payload = match.group(1).strip()
63
+ try:
64
+ data = json.loads(payload)
65
+ except json.JSONDecodeError:
66
+ return None, None, "Invalid JSON in <tool_call> block."
67
+
68
+ name = data.get("name")
69
+ arguments = data.get("arguments", {})
70
+ if not isinstance(name, str) or not isinstance(arguments, dict):
71
+ return None, None, "Invalid tool format. Expect name(str) and arguments(dict)."
72
+ return name, arguments, None
73
+
74
+
75
+ def run_search(query: str, max_results: int = 5) -> Dict[str, Any]:
76
+ if not query.strip():
77
+ return {"ok": False, "error": "Search query cannot be empty."}
78
+ rows: List[Dict[str, str]] = []
79
+ with DDGS() as ddgs:
80
+ for item in ddgs.text(query, max_results=max_results):
81
+ rows.append(
82
+ {
83
+ "title": item.get("title", ""),
84
+ "href": item.get("href", ""),
85
+ "body": item.get("body", ""),
86
+ }
87
+ )
88
+ return {"ok": True, "query": query, "results": rows}
89
+
90
+
91
+ def _clean_html_to_text(html: str, max_chars: int) -> str:
92
+ soup = BeautifulSoup(html, "html.parser")
93
+ for tag in soup(["script", "style", "noscript"]):
94
+ tag.decompose()
95
+ text = soup.get_text(separator=" ", strip=True)
96
+ text = re.sub(r"\s+", " ", text)
97
+ return text[:max_chars]
98
+
99
+
100
+ def run_visit(url: str, max_chars: int = 6000) -> Dict[str, Any]:
101
+ if not url.strip():
102
+ return {"ok": False, "error": "URL cannot be empty."}
103
+ try:
104
+ resp = requests.get(
105
+ url,
106
+ timeout=20,
107
+ headers={"User-Agent": "Mozilla/5.0 (compatible; DeepResearchSpace/1.0)"},
108
+ )
109
+ resp.raise_for_status()
110
+ content_type = resp.headers.get("content-type", "")
111
+ if "text/html" in content_type or "<html" in resp.text[:200].lower():
112
+ text = _clean_html_to_text(resp.text, max_chars=max_chars)
113
+ else:
114
+ text = resp.text[:max_chars]
115
+ return {"ok": True, "url": url, "content": text}
116
+ except Exception as exc:
117
+ return {"ok": False, "url": url, "error": str(exc)}
118
+
119
+
120
+ def call_model(
121
+ client: InferenceClient,
122
+ messages: List[Dict[str, str]],
123
+ model: str,
124
+ temperature: float,
125
+ max_new_tokens: int,
126
+ ) -> str:
127
+ completion = client.chat_completion(
128
+ model=model,
129
+ messages=messages,
130
+ temperature=temperature,
131
+ max_tokens=max_new_tokens,
132
+ )
133
+ return completion.choices[0].message.content or ""
134
+
135
+
136
+ def build_research_agent(
137
+ question: str,
138
+ model: str,
139
+ max_turns: int,
140
+ max_search_results: int,
141
+ temperature: float,
142
+ ) -> Tuple[str, str]:
143
+ token = os.getenv("HF_TOKEN")
144
+ client = InferenceClient(token=token)
145
+ state = AgentState()
146
+
147
+ messages: List[Dict[str, str]] = [
148
+ {"role": "system", "content": SYSTEM_PROMPT},
149
+ {"role": "user", "content": question},
150
+ ]
151
+
152
+ final_answer: Optional[str] = None
153
+
154
+ for turn in range(1, max_turns + 1):
155
+ model_output = call_model(
156
+ client=client,
157
+ messages=messages,
158
+ model=model,
159
+ temperature=temperature,
160
+ max_new_tokens=1400,
161
+ )
162
+ messages.append({"role": "assistant", "content": model_output})
163
+ state.trace.append({"turn": turn, "assistant": model_output})
164
+
165
+ extracted_answer = extract_answer(model_output)
166
+ if extracted_answer:
167
+ final_answer = extracted_answer
168
+ break
169
+
170
+ tool_name, tool_args, tool_err = parse_tool_call(model_output)
171
+ if tool_err:
172
+ tool_response = {"ok": False, "error": tool_err}
173
+ elif not tool_name:
174
+ # No explicit tool call and no final answer: force finalization.
175
+ messages.append(
176
+ {
177
+ "role": "user",
178
+ "content": "No tool call detected. Provide your best final answer in <answer>...</answer> now.",
179
+ }
180
+ )
181
+ continue
182
+ else:
183
+ if tool_name == "search":
184
+ query = str(tool_args.get("query", "")).strip()
185
+ max_results = int(tool_args.get("max_results", max_search_results))
186
+ max_results = max(1, min(max_results, 10))
187
+ if query:
188
+ state.searched_queries.append(query)
189
+ tool_response = run_search(query=query, max_results=max_results)
190
+ elif tool_name == "visit":
191
+ url = str(tool_args.get("url", "")).strip()
192
+ max_chars = int(tool_args.get("max_chars", 6000))
193
+ max_chars = max(500, min(max_chars, 20000))
194
+ if url:
195
+ state.visited_urls.append(url)
196
+ tool_response = run_visit(url=url, max_chars=max_chars)
197
+ else:
198
+ tool_response = {"ok": False, "error": f"Unknown tool: {tool_name}"}
199
+
200
+ state.trace.append({"turn": turn, "tool": tool_name, "tool_response": tool_response})
201
+ messages.append(
202
+ {
203
+ "role": "user",
204
+ "content": TOOL_RESPONSE_TEMPLATE.format(
205
+ payload=json.dumps(tool_response, ensure_ascii=False)
206
+ ),
207
+ }
208
+ )
209
+
210
+ if final_answer is None:
211
+ final_answer = (
212
+ "I could not finish a complete research answer within the configured turns. "
213
+ "Try increasing max turns or switching to a stronger model."
214
+ )
215
+
216
+ citations = "\n".join(f"- {url}" for url in sorted(set(state.visited_urls)))
217
+ if citations:
218
+ final_answer = f"{final_answer}\n\n### Visited Sources\n{citations}"
219
+
220
+ trace_text = json.dumps(
221
+ {
222
+ "searched_queries": state.searched_queries,
223
+ "visited_urls": state.visited_urls,
224
+ "trace": state.trace,
225
+ },
226
+ ensure_ascii=False,
227
+ indent=2,
228
+ )
229
+ return final_answer, trace_text
230
+
231
+
232
+ def run_ui(
233
+ question: str,
234
+ model: str,
235
+ max_turns: int,
236
+ max_search_results: int,
237
+ temperature: float,
238
+ ):
239
+ if not question.strip():
240
+ return "Please input a question.", "{}"
241
+ try:
242
+ return build_research_agent(
243
+ question=question,
244
+ model=model,
245
+ max_turns=max_turns,
246
+ max_search_results=max_search_results,
247
+ temperature=temperature,
248
+ )
249
+ except Exception as exc:
250
+ return f"Error: {exc}", json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2)
251
+
252
+
253
+ with gr.Blocks(title="DeepResearch Space Starter") as demo:
254
+ gr.Markdown(
255
+ """
256
+ # DeepResearch Space Starter
257
+ Ask a question, and the agent will iteratively search and visit pages before producing a final answer.
258
+
259
+ This starter uses a free HF Inference model by default. You can switch models later with environment variables.
260
+ """
261
+ )
262
+
263
+ with gr.Row():
264
+ with gr.Column(scale=2):
265
+ question = gr.Textbox(
266
+ label="Question",
267
+ placeholder="e.g. Compare top open-source deep research agents and summarize differences.",
268
+ lines=4,
269
+ )
270
+ model = gr.Textbox(label="Model", value=DEFAULT_MODEL)
271
+ with gr.Row():
272
+ max_turns = gr.Slider(label="Max Turns", minimum=2, maximum=20, value=8, step=1)
273
+ max_search_results = gr.Slider(
274
+ label="Search Results Per Query", minimum=1, maximum=10, value=5, step=1
275
+ )
276
+ temperature = gr.Slider(
277
+ label="Temperature", minimum=0.0, maximum=1.5, value=0.4, step=0.1
278
+ )
279
+ run_btn = gr.Button("Run Research", variant="primary")
280
+ with gr.Column(scale=3):
281
+ answer = gr.Markdown(label="Final Answer")
282
+ trace = gr.Code(label="Trace (JSON)", language="json")
283
+
284
+ run_btn.click(
285
+ fn=run_ui,
286
+ inputs=[question, model, max_turns, max_search_results, temperature],
287
+ outputs=[answer, trace],
288
+ )
289
+
290
+
291
+ if __name__ == "__main__":
292
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==5.29.0
2
+ huggingface_hub==0.31.2
3
+ duckduckgo_search==8.0.1
4
+ requests==2.32.3
5
+ beautifulsoup4==4.12.3