tillu-AI commited on
Commit
367656d
Β·
verified Β·
1 Parent(s): d8cd9ed

upload app/langgraph/research_agent.py

Browse files
Files changed (1) hide show
  1. app/langgraph/research_agent.py +616 -0
app/langgraph/research_agent.py ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Research Agent - LangGraph StateGraph Implementation
3
+
4
+ 7-Node Directed Graph with retry loops:
5
+ PLAN β†’ SEARCH β†’ SCRAPE β†’ EXTRACT β†’ SYNTHESIZE β†’ CRITIQUE β†’ STORE
6
+
7
+ Uses Cerebras for synthesis, Groq for planning/critique
8
+ """
9
+ from typing import TypedDict, List, Dict, Any, Optional, Annotated
10
+ from datetime import datetime
11
+ import operator
12
+ import time
13
+
14
+ from langgraph.graph import StateGraph, END
15
+ from langchain_groq import ChatGroq
16
+ from langchain_cerebras import ChatCerebras
17
+ from langchain.prompts import ChatPromptTemplate
18
+ from langchain.schema import SystemMessage, HumanMessage
19
+
20
+ from app.config import settings
21
+ from app.utils.logging import get_logger
22
+ from app.utils.database import db
23
+ from app.transformers.embeddings import embedding_generator
24
+ from app.tools.search_tools import WebSearchTool, BraveSearchTool
25
+ from app.transformers.extractors import NERExtractor, Summarizer
26
+
27
+ logger = get_logger("research_agent")
28
+
29
+
30
+ class ResearchState(TypedDict):
31
+ """State for research agent"""
32
+ task: str
33
+ user_id: str
34
+ research_plan: Dict[str, Any]
35
+ search_results: List[Dict[str, Any]]
36
+ scraped_content: List[Dict[str, Any]]
37
+ extracted_entities: List[Dict[str, Any]]
38
+ synthesis: str
39
+ critique: Dict[str, Any]
40
+ iteration_count: int
41
+ status: str
42
+ max_iterations: int
43
+ session_id: Optional[str]
44
+
45
+
46
+ class ResearchAgent:
47
+ """
48
+ LangGraph Research Agent
49
+ 7-node state machine for deep research tasks
50
+ """
51
+
52
+ def __init__(self):
53
+ self.logger = get_logger("research_agent")
54
+ self.workflow = self._build_workflow()
55
+
56
+ # Initialize LLMs
57
+ self.planning_llm = ChatGroq(
58
+ api_key=settings.groq_api_key,
59
+ model_name="llama-3.1-8b-instant",
60
+ temperature=0.7
61
+ ) if settings.groq_api_key else None
62
+
63
+ self.synthesis_llm = ChatCerebras(
64
+ api_key=settings.cerebras_api_key,
65
+ model_name="llama-3.3-70b",
66
+ temperature=0.6
67
+ ) if settings.cerebras_api_key else None
68
+
69
+ self.critique_llm = ChatGroq(
70
+ api_key=settings.groq_api_key,
71
+ model_name="llama-3.1-8b-instant",
72
+ temperature=0.5
73
+ ) if settings.groq_api_key else None
74
+
75
+ # Tools
76
+ self.web_search = WebSearchTool()
77
+ self.brave_search = BraveSearchTool()
78
+ self.ner = NERExtractor()
79
+ self.summarizer = Summarizer()
80
+
81
+ def _build_workflow(self) -> StateGraph:
82
+ """Build the 7-node research workflow"""
83
+
84
+ workflow = StateGraph(ResearchState)
85
+
86
+ # Add nodes
87
+ workflow.add_node("plan", self._plan_node)
88
+ workflow.add_node("search", self._search_node)
89
+ workflow.add_node("scrape", self._scrape_node)
90
+ workflow.add_node("extract", self._extract_node)
91
+ workflow.add_node("synthesize", self._synthesize_node)
92
+ workflow.add_node("critique", self._critique_node)
93
+ workflow.add_node("store", self._store_node)
94
+
95
+ # Add edges
96
+ workflow.set_entry_point("plan")
97
+ workflow.add_edge("plan", "search")
98
+ workflow.add_edge("search", "scrape")
99
+ workflow.add_edge("scrape", "extract")
100
+ workflow.add_edge("extract", "synthesize")
101
+ workflow.add_edge("synthesize", "critique")
102
+
103
+ # Conditional edge from critique
104
+ workflow.add_conditional_edges(
105
+ "critique",
106
+ self._critique_router,
107
+ {
108
+ "search_again": "search",
109
+ "store": "store",
110
+ "max_iterations": "store"
111
+ }
112
+ )
113
+
114
+ workflow.add_edge("store", END)
115
+
116
+ return workflow.compile()
117
+
118
+ async def _plan_node(self, state: ResearchState) -> ResearchState:
119
+ """
120
+ PLAN NODE
121
+ β†’ LLM decomposes topic into research angles
122
+ β†’ Identifies optimal sources per angle
123
+ """
124
+ self.logger.info("Research: Planning phase", task=state["task"])
125
+
126
+ if not self.planning_llm:
127
+ # Fallback plan
128
+ state["research_plan"] = {
129
+ "angles": ["general overview", "recent developments", "expert opinions"],
130
+ "sources": ["web", "news"],
131
+ "estimated_steps": 3
132
+ }
133
+ return state
134
+
135
+ try:
136
+ prompt = f"""You are a research planner. Break down the following research task into specific angles and identify the best sources for each.
137
+
138
+ Task: {state["task"]}
139
+
140
+ Output a JSON-like structure with:
141
+ - angles: list of 3-5 specific research angles
142
+ - sources: recommended sources for each angle (web, academic, news, etc.)
143
+ - key_questions: specific questions to answer
144
+
145
+ Be concise and specific."""
146
+
147
+ response = await self.planning_llm.ainvoke([HumanMessage(content=prompt)])
148
+
149
+ # Parse plan from response
150
+ plan_text = response.content
151
+
152
+ state["research_plan"] = {
153
+ "raw_plan": plan_text,
154
+ "angles": self._extract_angles(plan_text),
155
+ "sources": ["web", "news", "academic"],
156
+ "estimated_steps": 3
157
+ }
158
+
159
+ except Exception as e:
160
+ self.logger.error(f"Planning error: {e}")
161
+ state["research_plan"] = {
162
+ "angles": [state["task"]],
163
+ "sources": ["web"],
164
+ "error": str(e)
165
+ }
166
+
167
+ state["status"] = "planning_complete"
168
+ return state
169
+
170
+ def _extract_angles(self, plan_text: str) -> List[str]:
171
+ """Extract research angles from plan text"""
172
+ import re
173
+ angles = []
174
+
175
+ # Look for numbered lists or bullet points
176
+ lines = plan_text.split('\n')
177
+ for line in lines:
178
+ # Match patterns like "1. angle" or "- angle" or "* angle"
179
+ match = re.match(r'^[\s]*[\d\-\*\.]\s*[\.)]?\s*(.+)', line)
180
+ if match:
181
+ angles.append(match.group(1).strip())
182
+
183
+ if not angles:
184
+ angles = [plan_text[:100]] # Fallback
185
+
186
+ return angles[:5] # Max 5 angles
187
+
188
+ async def _search_node(self, state: ResearchState) -> ResearchState:
189
+ """
190
+ SEARCH NODE (parallel)
191
+ β†’ SearXNG: meta-search across all engines
192
+ β†’ ArXiv: academic papers
193
+ β†’ GitHub: technical repositories
194
+ β†’ Reddit: community perspectives
195
+ """
196
+ self.logger.info("Research: Search phase", angles=state["research_plan"].get("angles", []))
197
+
198
+ search_results = []
199
+
200
+ # Search for each angle
201
+ for angle in state["research_plan"].get("angles", [state["task"]])[:3]:
202
+ try:
203
+ # Web search via SearXNG
204
+ web_result = await self.web_search.execute(
205
+ query=angle,
206
+ num_results=5
207
+ )
208
+
209
+ if web_result.get("success"):
210
+ for r in web_result.get("results", []):
211
+ search_results.append({
212
+ "url": r.get("url"),
213
+ "title": r.get("title"),
214
+ "snippet": r.get("content", ""),
215
+ "source": "web",
216
+ "angle": angle
217
+ })
218
+
219
+ # Brave search for diversity
220
+ brave_result = await self.brave_search.execute(
221
+ query=angle,
222
+ num_results=3
223
+ )
224
+
225
+ if brave_result.get("success"):
226
+ for r in brave_result.get("results", []):
227
+ search_results.append({
228
+ "url": r.get("url"),
229
+ "title": r.get("title"),
230
+ "snippet": r.get("description", ""),
231
+ "source": "brave",
232
+ "angle": angle
233
+ })
234
+
235
+ except Exception as e:
236
+ self.logger.error(f"Search error for angle {angle}: {e}")
237
+
238
+ # Deduplicate by URL
239
+ seen_urls = set()
240
+ unique_results = []
241
+ for r in search_results:
242
+ if r["url"] and r["url"] not in seen_urls:
243
+ seen_urls.add(r["url"])
244
+ unique_results.append(r)
245
+
246
+ state["search_results"] = unique_results[:15] # Top 15
247
+ state["status"] = "search_complete"
248
+
249
+ self.logger.info(f"Found {len(unique_results)} unique results")
250
+ return state
251
+
252
+ async def _scrape_node(self, state: ResearchState) -> ResearchState:
253
+ """
254
+ SCRAPE NODE
255
+ β†’ Playwright renders each URL
256
+ β†’ BART summarizes each page (100-200 words)
257
+ """
258
+ self.logger.info("Research: Scraping phase", urls=len(state["search_results"]))
259
+
260
+ scraped = []
261
+
262
+ # Scrape top results (limit to avoid timeouts)
263
+ for result in state["search_results"][:8]:
264
+ try:
265
+ url = result.get("url")
266
+ if not url:
267
+ continue
268
+
269
+ # For now, use the snippet as content
270
+ # In production, use Playwright to render
271
+ content = result.get("snippet", "")
272
+
273
+ # Summarize if content is long
274
+ if len(content) > 300:
275
+ summary = await self.summarizer.summarize(
276
+ content,
277
+ max_length=200,
278
+ min_length=50
279
+ )
280
+ else:
281
+ summary = content
282
+
283
+ scraped.append({
284
+ "url": url,
285
+ "title": result.get("title"),
286
+ "summary": summary,
287
+ "source": result.get("source"),
288
+ "angle": result.get("angle"),
289
+ "word_count": len(summary.split())
290
+ })
291
+
292
+ except Exception as e:
293
+ self.logger.error(f"Scrape error for {result.get('url')}: {e}")
294
+
295
+ state["scraped_content"] = scraped
296
+ state["status"] = "scrape_complete"
297
+
298
+ self.logger.info(f"Scraped {len(scraped)} pages")
299
+ return state
300
+
301
+ async def _extract_node(self, state: ResearchState) -> ResearchState:
302
+ """
303
+ EXTRACT NODE
304
+ β†’ NER extracts entities (people, orgs, stats, dates)
305
+ """
306
+ self.logger.info("Research: Extraction phase")
307
+
308
+ all_entities = []
309
+
310
+ # Extract from all summaries
311
+ for content in state["scraped_content"]:
312
+ try:
313
+ text = content.get("summary", "")
314
+ if len(text) > 50:
315
+ entities = await self.ner.extract(text)
316
+ for e in entities:
317
+ e["source_url"] = content.get("url")
318
+ all_entities.extend(entities)
319
+ except Exception as e:
320
+ self.logger.error(f"NER error: {e}")
321
+
322
+ # Deduplicate entities
323
+ seen = set()
324
+ unique_entities = []
325
+ for e in all_entities:
326
+ key = f"{e.get('word', '').lower()}:{e.get('type', '')}"
327
+ if key not in seen and e.get('score', 0) > 0.7:
328
+ seen.add(key)
329
+ unique_entities.append(e)
330
+
331
+ state["extracted_entities"] = unique_entities[:20] # Top 20
332
+ state["status"] = "extract_complete"
333
+
334
+ return state
335
+
336
+ async def _synthesize_node(self, state: ResearchState) -> ResearchState:
337
+ """
338
+ SYNTHESIZE NODE
339
+ β†’ All summaries β†’ Cerebras 70B
340
+ β†’ Structured synthesis with citations
341
+ """
342
+ self.logger.info("Research: Synthesis phase")
343
+
344
+ if not self.synthesis_llm:
345
+ # Fallback synthesis
346
+ summaries = [c.get("summary", "") for c in state["scraped_content"]]
347
+ state["synthesis"] = "\n\n".join(summaries[:3])
348
+ return state
349
+
350
+ try:
351
+ # Build context from scraped content
352
+ context_parts = []
353
+ for i, content in enumerate(state["scraped_content"][:6], 1):
354
+ context_parts.append(
355
+ f"[{i}] {content.get('title', 'Untitled')}\n"
356
+ f"Source: {content.get('url', 'Unknown')}\n"
357
+ f"Summary: {content.get('summary', '')[:300]}\n"
358
+ )
359
+
360
+ context = "\n".join(context_parts)
361
+
362
+ # Build entities list
363
+ entities_text = "\n".join([
364
+ f"- {e.get('word')} ({e.get('type')})"
365
+ for e in state["extracted_entities"][:10]
366
+ ])
367
+
368
+ prompt = f"""Synthesize the following research findings into a comprehensive analysis.
369
+
370
+ Research Task: {state["task"]}
371
+
372
+ Sources:
373
+ {context}
374
+
375
+ Key Entities Found:
376
+ {entities_text}
377
+
378
+ Provide a structured synthesis with:
379
+ 1. Executive Summary (3-4 sentences)
380
+ 2. Key Findings (bullet points with citations [1], [2], etc.)
381
+ 3. Important Entities (people, organizations, dates mentioned)
382
+ 4. Contradictions or gaps in sources
383
+ 5. Conclusion
384
+
385
+ Be thorough but concise."""
386
+
387
+ response = await self.synthesis_llm.ainvoke([HumanMessage(content=prompt)])
388
+ state["synthesis"] = response.content
389
+
390
+ except Exception as e:
391
+ self.logger.error(f"Synthesis error: {e}")
392
+ state["synthesis"] = f"Error during synthesis: {str(e)}"
393
+
394
+ state["status"] = "synthesis_complete"
395
+ return state
396
+
397
+ async def _critique_node(self, state: ResearchState) -> ResearchState:
398
+ """
399
+ CRITIQUE NODE
400
+ β†’ Groq 8B evaluates synthesis depth
401
+ β†’ If shallow β†’ back to SEARCH NODE (max 3 iterations)
402
+ β†’ If sufficient β†’ STORE NODE
403
+ """
404
+ self.logger.info("Research: Critique phase", iteration=state["iteration_count"])
405
+
406
+ critique_result = {
407
+ "depth_score": 0.7,
408
+ "needs_more_research": False,
409
+ "feedback": ""
410
+ }
411
+
412
+ if not self.critique_llm:
413
+ state["critique"] = critique_result
414
+ return state
415
+
416
+ try:
417
+ prompt = f"""Critique the following research synthesis. Evaluate:
418
+ 1. Depth (1-10): Does it cover the topic thoroughly?
419
+ 2. Accuracy: Are claims supported by sources?
420
+ 3. Completeness: Are there obvious gaps?
421
+
422
+ Synthesis:
423
+ {state["synthesis"][:1500]} # Truncate for token limit
424
+
425
+ Respond in this format:
426
+ Depth Score: [1-10]
427
+ Needs More Research: [Yes/No]
428
+ Feedback: [Specific suggestions for improvement]"""
429
+
430
+ response = await self.critique_llm.ainvoke([HumanMessage(content=prompt)])
431
+ critique_text = response.content
432
+
433
+ # Parse critique
434
+ critique_result = self._parse_critique(critique_text)
435
+
436
+ except Exception as e:
437
+ self.logger.error(f"Critique error: {e}")
438
+ critique_result["feedback"] = f"Error: {str(e)}"
439
+
440
+ state["critique"] = critique_result
441
+ state["iteration_count"] = state.get("iteration_count", 0) + 1
442
+
443
+ return state
444
+
445
+ def _parse_critique(self, text: str) -> Dict[str, Any]:
446
+ """Parse critique response"""
447
+ result = {
448
+ "depth_score": 7,
449
+ "needs_more_research": False,
450
+ "feedback": text
451
+ }
452
+
453
+ import re
454
+
455
+ # Extract depth score
456
+ score_match = re.search(r'Depth Score:\s*(\d+)', text)
457
+ if score_match:
458
+ result["depth_score"] = int(score_match.group(1))
459
+
460
+ # Check if needs more research
461
+ if "Yes" in text and "Needs More Research" in text:
462
+ result["needs_more_research"] = True
463
+
464
+ return result
465
+
466
+ def _critique_router(self, state: ResearchState) -> str:
467
+ """Route based on critique results"""
468
+ iteration = state.get("iteration_count", 0)
469
+ max_iterations = state.get("max_iterations", 3)
470
+ critique = state.get("critique", {})
471
+
472
+ # Max iterations reached
473
+ if iteration >= max_iterations:
474
+ self.logger.info(f"Max iterations ({max_iterations}) reached, storing")
475
+ return "max_iterations"
476
+
477
+ # Needs more research and depth is low
478
+ if critique.get("needs_more_research") and critique.get("depth_score", 7) < 6:
479
+ self.logger.info(f"Iteration {iteration}: Needs more research")
480
+ return "search_again"
481
+
482
+ # Sufficient quality
483
+ self.logger.info(f"Iteration {iteration}: Synthesis sufficient")
484
+ return "store"
485
+
486
+ async def _store_node(self, state: ResearchState) -> ResearchState:
487
+ """
488
+ STORE NODE
489
+ β†’ Full research β†’ Supabase research_sessions
490
+ β†’ Embeddings generated β†’ pgvector
491
+ β†’ Summary β†’ knowledge_base
492
+ """
493
+ self.logger.info("Research: Storing results")
494
+
495
+ try:
496
+ # Create research session record
497
+ research_data = {
498
+ "user_id": state["user_id"],
499
+ "query": state["task"],
500
+ "research_plan": state["research_plan"],
501
+ "search_results": state["search_results"],
502
+ "scraped_content": state["scraped_content"],
503
+ "synthesis": state["synthesis"],
504
+ "critique": state["critique"],
505
+ "iteration_count": state.get("iteration_count", 1),
506
+ "executive_summary": self._extract_executive_summary(state["synthesis"]),
507
+ "citations": [{"url": c.get("url"), "title": c.get("title")}
508
+ for c in state["scraped_content"][:5]],
509
+ "status": "complete"
510
+ }
511
+
512
+ # Generate embedding for the synthesis
513
+ embedding = await embedding_generator.generate(
514
+ state["synthesis"][:1000] # First 1000 chars for embedding
515
+ )
516
+ if embedding:
517
+ research_data["embedding"] = embedding
518
+
519
+ # Store in database
520
+ result = await db.insert("research_sessions", research_data)
521
+
522
+ if result:
523
+ self.logger.info(f"Research stored: {result[0]['id']}")
524
+ state["research_id"] = result[0]["id"]
525
+
526
+ except Exception as e:
527
+ self.logger.error(f"Store error: {e}")
528
+
529
+ state["status"] = "complete"
530
+ return state
531
+
532
+ def _extract_executive_summary(self, synthesis: str) -> str:
533
+ """Extract executive summary from synthesis"""
534
+ lines = synthesis.split('\n')
535
+
536
+ # Look for executive summary section
537
+ in_summary = False
538
+ summary_lines = []
539
+
540
+ for line in lines:
541
+ if 'executive summary' in line.lower() or 'summary' in line.lower():
542
+ in_summary = True
543
+ continue
544
+
545
+ if in_summary:
546
+ if line.strip() and not line.startswith('#'):
547
+ summary_lines.append(line.strip())
548
+ elif len(summary_lines) > 3:
549
+ break
550
+
551
+ if summary_lines:
552
+ return ' '.join(summary_lines[:4])
553
+
554
+ # Fallback: first paragraph
555
+ return ' '.join(lines[:3])[:500]
556
+
557
+ async def execute(self, task: str, user_id: str) -> Dict[str, Any]:
558
+ """
559
+ Execute full research workflow
560
+
561
+ Args:
562
+ task: Research query/topic
563
+ user_id: User ID
564
+
565
+ Returns:
566
+ Research results
567
+ """
568
+ start_time = time.time()
569
+
570
+ # Initialize state
571
+ initial_state = {
572
+ "task": task,
573
+ "user_id": user_id,
574
+ "research_plan": {},
575
+ "search_results": [],
576
+ "scraped_content": [],
577
+ "extracted_entities": [],
578
+ "synthesis": "",
579
+ "critique": {},
580
+ "iteration_count": 0,
581
+ "status": "started",
582
+ "max_iterations": 3
583
+ }
584
+
585
+ # Run workflow
586
+ try:
587
+ result = await self.workflow.ainvoke(initial_state)
588
+
589
+ elapsed = time.time() - start_time
590
+
591
+ return {
592
+ "success": True,
593
+ "query": task,
594
+ "synthesis": result.get("synthesis"),
595
+ "executive_summary": result.get("synthesis", "")[:500],
596
+ "sources": [{"url": c.get("url"), "title": c.get("title")}
597
+ for c in result.get("scraped_content", [])],
598
+ "entities": result.get("extracted_entities", []),
599
+ "iterations": result.get("iteration_count", 1),
600
+ "research_id": result.get("research_id"),
601
+ "elapsed_seconds": elapsed,
602
+ "status": result.get("status")
603
+ }
604
+
605
+ except Exception as e:
606
+ self.logger.error(f"Research workflow error: {e}")
607
+ return {
608
+ "success": False,
609
+ "error": str(e),
610
+ "query": task
611
+ }
612
+
613
+
614
+ def create_research_agent() -> ResearchAgent:
615
+ """Factory function to create research agent"""
616
+ return ResearchAgent()