Lui3ui3ui commited on
Commit
173c019
Β·
verified Β·
1 Parent(s): 4a804c6

Upload 7 files

Browse files
Files changed (6) hide show
  1. .gitignore +1 -0
  2. README.md +3 -13
  3. agents.py +192 -0
  4. app.py +38 -0
  5. requirements.txt +5 -0
  6. search.py +35 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ _pycache_/
README.md CHANGED
@@ -1,13 +1,3 @@
1
- ---
2
- title: BookRecommender
3
- emoji: πŸ‘
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 5.35.0
8
- app_file: app.py
9
- pinned: false
10
- short_description: Recommends books by utilising web search.
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ A simple book recommendation agent.
2
+ Utilises ollama/llama3 and web search via DuckDuckGo.
3
+ Usage is pretty self explanatory.
 
 
 
 
 
 
 
 
 
 
agents.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph.graph import StateGraph, END
2
+ from search import duckduckgo_search
3
+ import ollama
4
+ import asyncio
5
+ import re
6
+ import json
7
+ import asyncio
8
+ import ast
9
+
10
+ class AsyncLogger:
11
+ def __init__(self):
12
+ self._log = []
13
+ self._lock = asyncio.Lock()
14
+
15
+ async def log(self, message):
16
+ async with self._lock:
17
+ self._log.append(message)
18
+
19
+ async def get_log(self):
20
+ async with self._lock:
21
+ return "\n".join(self._log)
22
+
23
+ async def clear(self):
24
+ async with self._lock:
25
+ self._log.clear()
26
+
27
+ logger = AsyncLogger()
28
+
29
+ def extract_json_array(text):
30
+ # Extract JSON block from anywhere in the text
31
+ pattern = r"(\[.*?\])" # non-greedy match to get the smallest bracketed block
32
+ matches = re.findall(pattern, text, flags=re.DOTALL)
33
+
34
+ for candidate in matches:
35
+ try:
36
+ # Attempt to load as JSON
37
+ return json.loads(candidate)
38
+ except json.JSONDecodeError as e:
39
+ print(f"json.loads error: {e}")
40
+ continue
41
+
42
+ return []
43
+
44
+ # Node 1: Extract books from user input
45
+ async def extract_books_node(state):
46
+ await logger.clear()
47
+ user_input = state.get("user_input", "")
48
+ prompt = (
49
+ "Extract all book titles and authors from the following text. "
50
+ "If an author is missing, fill it in using your knowledge. "
51
+ "Output only a JSON list of dicts like this:\n"
52
+ '[{"title": "...", "author": "..."}, ...]\n\n'
53
+ f"User input: {user_input}"
54
+ )
55
+ response = ollama.chat(model="llama3", messages=[{"role": "user", "content": prompt}])
56
+ content = response['message']['content']
57
+
58
+ print("[extract_books_node] LLM raw response:", content)
59
+ await logger.log(f"[extract_books_node] LLM response: {content}")
60
+
61
+ books = extract_json_array(content)
62
+
63
+ if not books:
64
+ await logger.log("[extract_books_node] Failed to extract valid book list from LLM response.")
65
+ else:
66
+ await logger.log(f"[extract_books_node] Extracted books: {books}")
67
+
68
+ print("[extract_books_node] Extracted books:", books)
69
+
70
+ return {"extracted_books": books}
71
+
72
+ # Node 2
73
+ async def recommend_books_node(state):
74
+ extracted_books = state.get("extracted_books", [])
75
+ reasoning_steps = []
76
+ recommended_books = []
77
+
78
+ print("[recommend_books_node] Extracted books:", extracted_books)
79
+ await logger.log(f"[recommend_books_node] Extracted books: {extracted_books}")
80
+
81
+ if not extracted_books:
82
+ reasoning_steps.append("No books extracted from the input. Check if the extraction failed.")
83
+ return {"recommendations": [], "reasoning": "\n".join(reasoning_steps)}
84
+
85
+ for book in extracted_books:
86
+ title = book.get("title", "")
87
+ author = book.get("author", "")
88
+ query = f"Books similar to '{title}' by {author}"
89
+ reasoning_steps.append(f"Searching DuckDuckGo with query: {query}")
90
+
91
+ print(f"[recommend_books_node] Searching with query: {query}")
92
+ await logger.log(f"Searching DuckDuckGo with query: {query}")
93
+
94
+ search_results = await duckduckgo_search(query)
95
+
96
+ if not search_results:
97
+ reasoning_steps.append(f"No results found for: {query}")
98
+ print(f"[recommend_books_node] No results found for query: {query}")
99
+ await logger.log(f"No results found for query: {query}")
100
+ continue
101
+
102
+ print(f"[recommend_books_node] Results for query '{query}': {search_results}")
103
+ await logger.log(f"Results for query '{query}': {search_results}")
104
+
105
+ for res in search_results:
106
+ recommended_books.append({
107
+ "title": res.get("title", "No Title"),
108
+ "link": res.get("link", ""),
109
+ "snippet": res.get("snippet", "")
110
+ })
111
+ reasoning_steps.append(f"βœ… Found: {res.get('title', 'No Title')} ({res.get('link', '')})")
112
+
113
+ if not recommended_books:
114
+ reasoning_steps.append("No recommendations found across all queries.")
115
+
116
+ print("[recommend_books_node] Final recommendations:", recommended_books)
117
+ await logger.log(f"Final recommendations: {recommended_books}")
118
+
119
+ return {
120
+ "recommendations": recommended_books,
121
+ "reasoning": "\n".join(reasoning_steps)
122
+ }
123
+
124
+ # Node 3: Reason about the search results and generate recommendations
125
+ async def reasoning_node(state):
126
+ recommendations = state.get("recommendations", [])
127
+ initial_reasoning = state.get("reasoning", "")
128
+
129
+ if not recommendations:
130
+ final_reasoning = initial_reasoning + "\nNo recommendations found to reason about."
131
+ return {"final_recommendations": [], "final_reasoning": final_reasoning}
132
+
133
+ # Format recommendations as input for the LLM
134
+ recommendations_text = "\n".join(
135
+ [f"Title: {rec['title']}\nLink: {rec['link']}\nSnippet: {rec['snippet']}\n" for rec in recommendations]
136
+ )
137
+
138
+ prompt = (
139
+ "You are a helpful book recommendation expert. You are given a web search result. "
140
+ "Analyze it and select the most relevant book recommendations. Explain why you recommend each book. "
141
+ "Output only a JSON list like this:\n"
142
+ '[{"title": "...", "reason": "...", "link": "..."}, ...]\n\n'
143
+ "Do not add any explanations, comments, or extra text. Only output the JSON list.\n\n"
144
+ f"Books found from search:\n{recommendations_text}"
145
+ )
146
+
147
+
148
+ response = ollama.chat(model="llama3", messages=[{"role": "user", "content": prompt}])
149
+ content = response['message']['content']
150
+
151
+ print("[reasoning_node] LLM raw response:", content)
152
+ await logger.log(f"[reasoning_node] LLM response: {content}")
153
+
154
+ # Extract JSON-like structure
155
+ final_recommendations = extract_json_array(content)
156
+
157
+ if not final_recommendations:
158
+ await logger.log("[reasoning_node] Failed to extract final recommendations from LLM response.")
159
+ else:
160
+ await logger.log(f"[reasoning_node] Final recommendations: {final_recommendations}")
161
+
162
+ # Combine previous reasoning with the final reasoning
163
+ final_reasoning = initial_reasoning + "\n\nFinal reasoning:\n"
164
+ for rec in final_recommendations:
165
+ final_reasoning += f"βœ… Recommended: {rec.get('title', 'Unknown')} - {rec.get('reason', 'No reason provided.')}\n"
166
+
167
+ print("[reasoning_node] Final recommendations extracted:", final_recommendations)
168
+ print("[reasoning_node] Final reasoning:\n", final_reasoning)
169
+ await logger.log(f"[reasoning_node] Final recommendations extracted: {final_recommendations}")
170
+ await logger.log(f"[reasoning_node] Final reasoning:\n{final_reasoning}")
171
+
172
+ return {
173
+ "final_recommendations": final_recommendations,
174
+ "final_reasoning": final_reasoning
175
+ }
176
+
177
+
178
+ # Build the graph
179
+ def build_graph():
180
+ graph = StateGraph(dict)
181
+
182
+ graph.add_node("extract_books", extract_books_node)
183
+ graph.add_node("recommend_books", recommend_books_node)
184
+ graph.add_node("reasoning", reasoning_node)
185
+
186
+ # Define edges
187
+ graph.add_edge("extract_books", "recommend_books")
188
+ graph.add_edge("recommend_books", "reasoning")
189
+ graph.add_edge("reasoning", END)
190
+
191
+ graph.set_entry_point("extract_books")
192
+ return graph.compile()
app.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from agents import build_graph
3
+ import asyncio
4
+
5
+ # Build the LangGraph once
6
+ graph = build_graph()
7
+
8
+ async def run_book_recommender(user_input):
9
+ initial_state = {"user_input": user_input}
10
+
11
+ async for state in graph.astream(initial_state):
12
+ final_state = state
13
+
14
+ print("[app.py] Final state:", final_state)
15
+
16
+ # Access the nested "reasoning" key
17
+ reasoning_data = final_state.get("reasoning", {})
18
+ recommendations = reasoning_data.get("final_recommendations", [])
19
+ reasoning = reasoning_data.get("final_reasoning", "")
20
+
21
+ recommendations_text = "\n\n".join(
22
+ [f"πŸ“˜ {rec['title']}\nπŸ”— {rec.get('link', '')}\nπŸ’‘ {rec.get('reason', '')}" for rec in recommendations]
23
+ ) or "No recommendations found."
24
+
25
+ return recommendations_text, reasoning
26
+
27
+ # Gradio UI
28
+ with gr.Blocks() as demo:
29
+ gr.Markdown("# πŸ“š AI Book Recommender")
30
+ user_input = gr.Textbox(label="Tell me some books you like")
31
+ recommend_btn = gr.Button("Get Recommendations")
32
+ recommendations_output = gr.Textbox(label="Recommended Books", lines=10)
33
+ reasoning_output = gr.Textbox(label="Reasoning / Debug Log", lines=15)
34
+
35
+ recommend_btn.click(run_book_recommender, inputs=user_input, outputs=[recommendations_output, reasoning_output])
36
+
37
+ if __name__ == "__main__":
38
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ langgraph
2
+ ollama
3
+ gradio
4
+ httpx
5
+ selectolax
search.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # search.py (modify to accept logger)
2
+ import httpx
3
+ from selectolax.parser import HTMLParser
4
+
5
+ async def duckduckgo_search(query, max_results=5, logger=None):
6
+ if logger:
7
+ await logger.log(f"[duckduckgo_search] Searching for query: {query}")
8
+
9
+ url = f"https://html.duckduckgo.com/html/?q={query}"
10
+ headers = {"User-Agent": "Mozilla/5.0"}
11
+ async with httpx.AsyncClient() as client:
12
+ response = await client.get(url, headers=headers, timeout=10)
13
+
14
+ html = HTMLParser(response.text)
15
+ results = []
16
+
17
+ for result in html.css("div.result")[:max_results]:
18
+ title_el = result.css_first("a.result__a")
19
+ snippet_el = result.css_first(".result__snippet")
20
+
21
+ if title_el and snippet_el:
22
+ title = title_el.text(strip=True)
23
+ link = title_el.attributes.get("href", "")
24
+ snippet = snippet_el.text(strip=True)
25
+ results.append({"title": title, "link": link, "snippet": snippet})
26
+ if logger:
27
+ await logger.log(f"[duckduckgo_search] Found result: {title} - {link}")
28
+ else:
29
+ if logger:
30
+ await logger.log("[duckduckgo_search] Skipped a result due to missing title or snippet.")
31
+
32
+ if logger:
33
+ await logger.log(f"[duckduckgo_search] Total results found: {len(results)}")
34
+
35
+ return results