From 5136cf5c77f36e865c08ab7512b7589f8270b60a Mon Sep 17 00:00:00 2001 From: MaryChen68 Date: Wed, 27 May 2026 13:41:30 -0400 Subject: [PATCH 1/2] Add prompt evaluation script for comparing LLM output --- backend/eval_prompts.py | 305 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 backend/eval_prompts.py diff --git a/backend/eval_prompts.py b/backend/eval_prompts.py new file mode 100644 index 00000000..b0a6f6cf --- /dev/null +++ b/backend/eval_prompts.py @@ -0,0 +1,305 @@ +""" +Prompt evaluation script. + +Runs all prompt types against a set of test documents and prints the outputs +so you can eyeball whether the LLM is giving good results. + +Usage (run from the backend/ folder): + uv run python eval_prompts.py +""" + +import asyncio +import time +import openai +import os +from dataclasses import dataclass +from dotenv import load_dotenv +from pydantic import BaseModel +from typing import List + +load_dotenv() +client = openai.AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) + +MODEL = "gpt-4o" + + +# --------------------------------------------------------------------------- +# Minimal DocContext (mirrors nlp.py — no PostHog dependency) +# --------------------------------------------------------------------------- + +@dataclass +class DocContext: + beforeCursor: str + selectedText: str + afterCursor: str + + +# --------------------------------------------------------------------------- +# Prompts (copied from nlp.py so this script is self-contained) +# --------------------------------------------------------------------------- + +PROMPTS = { + "example_sentences": """\ +You are assisting a writer in drafting a document. Generate three possible options for inspiring and fresh possible next sentences that would help the writer think about what they should write next. + +Guidelines: +- Focus on the area of the document that is closest to the writer's cursor. +- If the writer is in the middle of a sentence, output three possible continuations of that sentence. +- If the writer is at the end of a paragraph, output three possible sentences that would start the next paragraph. +- The three sentences should be three different paths that the writer could take, each starting from the current point in the document; they do **NOT** go in sequence. +- Each output should be *at most one sentence* long. +- Use ellipses to truncate sentences that are longer than about **10 words**. +""", + "proposal_advice": """\ +You are assisting a writer in drafting a document by providing three directive (but not prescriptive) advice to help them develop their work. Your advice must be tailored to the document's genre. Use your best judgment to offer the most relevant and helpful advice, drawing from the following types of support as appropriate for the context: +- Support the writer in adhering to their stated writing goals or assignment guidelines. +- Help the writer think about what they could write next. +- Encourage the writer to maintain focus on their main idea and avoid introducing unrelated material. +- Recommend strengthening arguments by adding supporting evidence, specific examples, or clear reasoning. +- Advise on structuring material to achieve a clear and logical flow. +- Guide the writer in choosing language that is accessible and engaging for the intended audience. + +Guidelines: +- Focus on the area of the document that is closest to the writer's cursor. +- Keep each piece of advice under 20 words. +- Express the advice in the form of a directive instruction, not a question. +- Don't give specific words or phrases for the writer to use. +- Make each piece of advice very specific to the current document, not general advice that could apply to any document. +""", + "analysis_readerPerspective": """\ +You are assisting a writer in drafting a document for a specific person. Generate three possible questions the person might have about the document so far. + +Guidelines: +- Avoid suggesting specific words or phrases. +- Limit each question to under 20 words. +- Ensure all questions specifically reflect details or qualities from the current document, avoiding broad or generic statements. +- Each question should be expressed as a perspective describing how the person might feel about the document, not as a directive to the writer. +- If there is insufficient context to generate genuine questions, return an empty list. +""", + "example_rewording": """\ +You are assisting a writer in drafting a document. Generate three alternative rewordings of the writer's selected text. + +Guidelines: +- Rephrase only the selected text in three different ways while preserving the original meaning. +- Vary the word choice, and tone across the three options. +- Maintain the writer's overall voice and style. +- Each rewording should be approximately the same length as the original selected text. +- If no text is selected, return an empty list. +""", +} + + +def get_full_prompt(prompt_name: str, doc: DocContext, context_chars: int = 100) -> str: + prompt = PROMPTS[prompt_name] + document_text = doc.beforeCursor + doc.selectedText + doc.afterCursor + prompt += f"\n\n# Writer's Document So Far\n\n\n{document_text}\n\n" + before_cursor_trim = doc.beforeCursor[-context_chars:] + after_cursor_trim = doc.afterCursor[:context_chars] + if doc.selectedText == "": + prompt += f'\n\n## Text Right Before the Cursor\n\n"{before_cursor_trim}"' + else: + prompt += f"\n\n## Current Selection\n\n{doc.selectedText}" + prompt += f'\n\n## Text Nearby The Selection\n\n"{before_cursor_trim}{doc.selectedText}{after_cursor_trim}"' + return prompt + + +# --------------------------------------------------------------------------- +# Test documents +# --------------------------------------------------------------------------- +# Each entry has a short name and a DocContext (beforeCursor, selectedText, afterCursor). +# These represent different writing situations the tool might encounter. + +TEST_DOCS = [ + { + "name": "Academic essay (mid-paragraph)", + "doc": DocContext( + beforeCursor=( + "The relationship between social media use and mental health has been " + "extensively studied over the past decade. Research consistently shows " + "that heavy use of platforms like Instagram and TikTok is correlated " + "with increased rates of anxiety and depression among teenagers. " + "However, the causal direction of this relationship remains unclear. " + ), + selectedText="", + afterCursor=( + " Some researchers argue that pre-existing mental health conditions " + "lead to higher social media use, rather than the reverse." + ), + ), + }, + { + "name": "Narrative story (mid-sentence)", + "doc": DocContext( + beforeCursor=( + "Maya had lived in the same apartment for six years, but tonight " + "everything felt different. She stood at the window watching the rain " + "streak down the glass, thinking about the letter she had received that " + "morning. It was from her father, whom she hadn't spoken to in " + ), + selectedText="", + afterCursor="", + ), + }, + { + "name": "Argumentative essay (beginning)", + "doc": DocContext( + beforeCursor=( + "Universal basic income (UBI) has gained significant attention as a " + "potential solution to growing economic inequality. Proponents argue " + "that providing every citizen with a guaranteed monthly income would " + "eliminate poverty, reduce bureaucracy, and give workers more bargaining " + "power. " + ), + selectedText="", + afterCursor="", + ), + }, + { + "name": "Professional email", + "doc": DocContext( + beforeCursor=( + "Hi Professor,\n\n" + "I wanted to follow up on our meeting last week regarding my thesis proposal. " + "I have made some changes based on your feedback and " + ), + selectedText="", + afterCursor="", + ), + }, + { + "name": "Rewording — selected text", + "doc": DocContext( + beforeCursor=( + "The data clearly demonstrates that students who receive consistent feedback " + "perform significantly better on standardized assessments. " + ), + selectedText="students who receive consistent feedback perform significantly better", + afterCursor=" on standardized assessments.", + ), + }, +] + +PROMPT_TYPES = [ + "example_sentences", + "proposal_advice", + "analysis_readerPerspective", + "example_rewording", +] + + +# --------------------------------------------------------------------------- +# Structured output format (same as nlp.py) +# --------------------------------------------------------------------------- + +class ListResponse(BaseModel): + responses: List[str] + + +# --------------------------------------------------------------------------- +# Run one prompt against one document +# --------------------------------------------------------------------------- + +async def run_one(prompt_name: str, doc: DocContext, model: str = MODEL) -> tuple[str, float]: + """Returns (output_text, latency_seconds).""" + full_prompt = get_full_prompt(prompt_name, doc) + messages = [ + {"role": "system", "content": "You are a helpful and insightful writing assistant."}, + {"role": "user", "content": full_prompt}, + ] + + start = time.perf_counter() + + # complete_document returns free-form text, everything else uses structured output + # to match the behaviour of nlp.get_suggestion() in the actual app + if prompt_name == "complete_document": + response = await client.chat.completions.create( + model=model, + messages=messages, + max_completion_tokens=512, + ) + elapsed = time.perf_counter() - start + return (response.choices[0].message.content or "").strip(), elapsed + + completion = await client.beta.chat.completions.parse( + model=model, + messages=messages, + response_format=ListResponse, + max_completion_tokens=512, + ) + elapsed = time.perf_counter() - start + parsed = completion.choices[0].message.parsed + if not parsed or not parsed.responses: + return "(empty)", elapsed + return "\n".join(f"- {item}" for item in parsed.responses), elapsed + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +async def main(model_a: str, model_b: str | None): + compare = model_b is not None + + if compare: + print(f"Comparing: {model_a} vs {model_b}\n") + else: + print(f"Model: {model_a}\n") + + for doc_entry in TEST_DOCS: + doc_name = doc_entry["name"] + doc: DocContext = doc_entry["doc"] + has_selection = bool(doc.selectedText.strip()) + + print("=" * 70) + print(f"DOCUMENT: {doc_name}") + print("=" * 70) + + for prompt_name in PROMPT_TYPES: + # example_rewording only makes sense when text is selected + if prompt_name == "example_rewording" and not has_selection: + continue + # skip non-rewording prompts on the rewording test doc + if prompt_name != "example_rewording" and has_selection: + continue + + if compare: + # Run both models at the same time + (out_a, lat_a), (out_b, lat_b) = await asyncio.gather( + run_one(prompt_name, doc, model_a), + run_one(prompt_name, doc, model_b), + ) + col = 34 + print(f"\n [{prompt_name}]") + print(f" {'─' * col} {'─' * col}") + print(f" {model_a + f' ({lat_a:.2f}s)':<{col}} {model_b} ({lat_b:.2f}s)") + print(f" {'─' * col} {'─' * col}") + lines_a = out_a.splitlines() + lines_b = out_b.splitlines() + for i in range(max(len(lines_a), len(lines_b))): + la = lines_a[i] if i < len(lines_a) else "" + lb = lines_b[i] if i < len(lines_b) else "" + la = (la[:col - 1] + "…") if len(la) > col else la + print(f" {la:<{col}} {lb}") + else: + output, latency = await run_one(prompt_name, doc, model_a) + print(f"\n [{prompt_name}] {latency:.2f}s") + print(" " + "-" * 50) + for line in output.splitlines(): + print(f" {line}") + + print() + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Evaluate writing tool prompts") + parser.add_argument("--compare", nargs=2, metavar="MODEL", + help="Compare two models, e.g. --compare gpt-4o gpt-4o-mini") + parser.add_argument("--model", default="gpt-4o", + help="Single model to use (default: gpt-4o)") + args = parser.parse_args() + + if args.compare: + asyncio.run(main(args.compare[0], args.compare[1])) + else: + asyncio.run(main(args.model, None)) From e98a30681d969cc485105e4502c9e2bc08177b98 Mon Sep 17 00:00:00 2001 From: MaryChen68 Date: Fri, 29 May 2026 15:05:18 -0400 Subject: [PATCH 2/2] Tests a bunch of writing prompts against sample documents to see how the LLM responds, and optionally saves the results to Langfuse for tracking. --- backend/eval_prompts.py | 223 ++++++++++++++++++++++++---------------- pyproject.toml | 1 + uv.lock | 80 ++++++++++++++ 3 files changed, 218 insertions(+), 86 deletions(-) diff --git a/backend/eval_prompts.py b/backend/eval_prompts.py index b0a6f6cf..a8a9ef26 100644 --- a/backend/eval_prompts.py +++ b/backend/eval_prompts.py @@ -4,28 +4,60 @@ Runs all prompt types against a set of test documents and prints the outputs so you can eyeball whether the LLM is giving good results. +When LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY/LANGFUSE_BASE_URL are present in .env, every +run is also logged to Langfuse so you can compare prompt versions in the UI. + Usage (run from the backend/ folder): +eyeball mode: uv run python eval_prompts.py + +compare two models side-by-side in terminal: + uv run python eval_prompts.py --compare gpt-4o gpt-4o-mini + +send results to Langfuse as an experiment (dataset is auto-created if missing) + uv run python eval_prompts.py --experiment +Example: + uv run python eval_prompts.py --model gpt-5.4 --experiment gpt-5.4 """ import asyncio +import sys import time -import openai import os from dataclasses import dataclass +from datetime import datetime, timezone + +import openai from dotenv import load_dotenv from pydantic import BaseModel -from typing import List + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") load_dotenv() -client = openai.AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) +if os.getenv("LANGFUSE_BASE_URL") and not os.getenv("LANGFUSE_HOST"): + os.environ["LANGFUSE_HOST"] = os.environ["LANGFUSE_BASE_URL"] + +client = openai.AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) MODEL = "gpt-4o" +DATASET_NAME = "writing-tools-eval" + +# Langfuse setup — optional; script works normally when keys are absent +_langfuse = None +_pub = os.getenv("LANGFUSE_PUBLIC_KEY", "") +_sec = os.getenv("LANGFUSE_SECRET_KEY", "") +_host = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com") +if _pub and _sec and not _pub.startswith("pk-lf-..."): + try: + from langfuse import Langfuse + _langfuse = Langfuse(public_key=_pub, secret_key=_sec, host=_host) + print(f"[langfuse] Tracing enabled: {_host}") + except Exception as e: + print(f"[langfuse] Failed to init: {e} - tracing disabled.") +else: + print("[langfuse] Keys not configured - tracing disabled.") - -# --------------------------------------------------------------------------- -# Minimal DocContext (mirrors nlp.py — no PostHog dependency) -# --------------------------------------------------------------------------- @dataclass class DocContext: @@ -34,10 +66,6 @@ class DocContext: afterCursor: str -# --------------------------------------------------------------------------- -# Prompts (copied from nlp.py so this script is self-contained) -# --------------------------------------------------------------------------- - PROMPTS = { "example_sentences": """\ You are assisting a writer in drafting a document. Generate three possible options for inspiring and fresh possible next sentences that would help the writer think about what they should write next. @@ -103,12 +131,6 @@ def get_full_prompt(prompt_name: str, doc: DocContext, context_chars: int = 100) return prompt -# --------------------------------------------------------------------------- -# Test documents -# --------------------------------------------------------------------------- -# Each entry has a short name and a DocContext (beforeCursor, selectedText, afterCursor). -# These represent different writing situations the tool might encounter. - TEST_DOCS = [ { "name": "Academic essay (mid-paragraph)", @@ -167,7 +189,7 @@ def get_full_prompt(prompt_name: str, doc: DocContext, context_chars: int = 100) ), }, { - "name": "Rewording — selected text", + "name": "Rewording - selected text", "doc": DocContext( beforeCursor=( "The data clearly demonstrates that students who receive consistent feedback " @@ -179,109 +201,69 @@ def get_full_prompt(prompt_name: str, doc: DocContext, context_chars: int = 100) }, ] -PROMPT_TYPES = [ - "example_sentences", - "proposal_advice", - "analysis_readerPerspective", - "example_rewording", -] - - -# --------------------------------------------------------------------------- -# Structured output format (same as nlp.py) -# --------------------------------------------------------------------------- class ListResponse(BaseModel): - responses: List[str] + responses: list[str] -# --------------------------------------------------------------------------- -# Run one prompt against one document -# --------------------------------------------------------------------------- - -async def run_one(prompt_name: str, doc: DocContext, model: str = MODEL) -> tuple[str, float]: - """Returns (output_text, latency_seconds).""" - full_prompt = get_full_prompt(prompt_name, doc) +async def run_one(prompt_name: str, doc: DocContext, model: str = MODEL) -> tuple: messages = [ {"role": "system", "content": "You are a helpful and insightful writing assistant."}, - {"role": "user", "content": full_prompt}, + {"role": "user", "content": get_full_prompt(prompt_name, doc)}, ] - - start = time.perf_counter() - - # complete_document returns free-form text, everything else uses structured output - # to match the behaviour of nlp.get_suggestion() in the actual app - if prompt_name == "complete_document": - response = await client.chat.completions.create( - model=model, - messages=messages, - max_completion_tokens=512, - ) - elapsed = time.perf_counter() - start - return (response.choices[0].message.content or "").strip(), elapsed - + start_dt = datetime.now(timezone.utc) + t0 = time.perf_counter() completion = await client.beta.chat.completions.parse( model=model, messages=messages, response_format=ListResponse, max_completion_tokens=512, ) - elapsed = time.perf_counter() - start + elapsed = time.perf_counter() - t0 + end_dt = datetime.now(timezone.utc) parsed = completion.choices[0].message.parsed - if not parsed or not parsed.responses: - return "(empty)", elapsed - return "\n".join(f"- {item}" for item in parsed.responses), elapsed - + result = "(empty)" if not parsed or not parsed.responses else "\n".join( + f"- {r}" for r in parsed.responses + ) + return result, elapsed, messages, start_dt, end_dt, completion.usage -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- -async def main(model_a: str, model_b: str | None): +async def eyeball(model_a: str, model_b: str | None = None): compare = model_b is not None - - if compare: - print(f"Comparing: {model_a} vs {model_b}\n") - else: - print(f"Model: {model_a}\n") + print(f"{'Comparing: ' + model_a + ' vs ' + model_b if compare else 'Model: ' + model_a}\n") for doc_entry in TEST_DOCS: - doc_name = doc_entry["name"] doc: DocContext = doc_entry["doc"] has_selection = bool(doc.selectedText.strip()) print("=" * 70) - print(f"DOCUMENT: {doc_name}") + print(f"DOCUMENT: {doc_entry['name']}") print("=" * 70) - for prompt_name in PROMPT_TYPES: - # example_rewording only makes sense when text is selected + for prompt_name in PROMPTS: if prompt_name == "example_rewording" and not has_selection: continue - # skip non-rewording prompts on the rewording test doc if prompt_name != "example_rewording" and has_selection: continue if compare: - # Run both models at the same time - (out_a, lat_a), (out_b, lat_b) = await asyncio.gather( + (out_a, lat_a, *_), (out_b, lat_b, *_) = await asyncio.gather( run_one(prompt_name, doc, model_a), run_one(prompt_name, doc, model_b), ) col = 34 print(f"\n [{prompt_name}]") - print(f" {'─' * col} {'─' * col}") + print(f" {'-' * col} {'-' * col}") print(f" {model_a + f' ({lat_a:.2f}s)':<{col}} {model_b} ({lat_b:.2f}s)") - print(f" {'─' * col} {'─' * col}") - lines_a = out_a.splitlines() - lines_b = out_b.splitlines() + print(f" {'-' * col} {'-' * col}") + lines_a, lines_b = out_a.splitlines(), out_b.splitlines() for i in range(max(len(lines_a), len(lines_b))): la = lines_a[i] if i < len(lines_a) else "" lb = lines_b[i] if i < len(lines_b) else "" - la = (la[:col - 1] + "…") if len(la) > col else la + la = (la[:col - 1] + "...") if len(la) > col else la print(f" {la:<{col}} {lb}") else: - output, latency = await run_one(prompt_name, doc, model_a) + output, latency, *_ = await run_one(prompt_name, doc, model_a) print(f"\n [{prompt_name}] {latency:.2f}s") print(" " + "-" * 50) for line in output.splitlines(): @@ -290,16 +272,85 @@ async def main(model_a: str, model_b: str | None): print() +async def run_experiment(model: str, run_name: str): + if _langfuse is None: + print("Langfuse not configured. Cannot run experiment.") + return + + try: + dataset = _langfuse.get_dataset(DATASET_NAME) + except Exception: + print(f"Dataset '{DATASET_NAME}' not found — creating it...") + _langfuse.create_dataset(name=DATASET_NAME) + for doc_entry in TEST_DOCS: + doc = doc_entry["doc"] + has_selection = bool(doc.selectedText.strip()) + for prompt_name in PROMPTS: + if prompt_name == "example_rewording" and not has_selection: + continue + if prompt_name != "example_rewording" and has_selection: + continue + _langfuse.create_dataset_item( + dataset_name=DATASET_NAME, + input={ + "doc_name": doc_entry["name"], + "prompt_name": prompt_name, + "beforeCursor": doc.beforeCursor, + "selectedText": doc.selectedText, + "afterCursor": doc.afterCursor, + }, + ) + _langfuse.flush() + dataset = _langfuse.get_dataset(DATASET_NAME) + print(f"Dataset created with {len(dataset.items)} items.") + + print(f"Experiment: '{run_name}' | model: {model} | items: {len(dataset.items)}\n") + + for item in dataset.items: + doc = DocContext( + beforeCursor=item.input["beforeCursor"], + selectedText=item.input["selectedText"], + afterCursor=item.input["afterCursor"], + ) + prompt_name = item.input["prompt_name"] + + output, latency, messages, start_dt, end_dt, usage = await run_one(prompt_name, doc, model) + + trace = _langfuse.trace(name=prompt_name, input=item.input, output=output, timestamp=start_dt) + trace.generation( + name="openai", + model=model, + input=messages, + output=output, + start_time=start_dt, + end_time=end_dt, + usage={ + "prompt_tokens": usage.prompt_tokens if usage else 0, + "completion_tokens": usage.completion_tokens if usage else 0, + }, + ) + item.link(trace, run_name=run_name) + + print(f" [{prompt_name}] {item.input['doc_name']} ({latency:.2f}s)") + for line in output.splitlines(): + print(f" {line}") + print() + + _langfuse.flush() + print(f"Done. Check Langfuse -> Datasets -> {DATASET_NAME} -> Experiments") + + if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Evaluate writing tool prompts") - parser.add_argument("--compare", nargs=2, metavar="MODEL", - help="Compare two models, e.g. --compare gpt-4o gpt-4o-mini") - parser.add_argument("--model", default="gpt-4o", - help="Single model to use (default: gpt-4o)") + parser.add_argument("--compare", nargs=2, metavar="MODEL", help="Compare two models side by side") + parser.add_argument("--model", default="gpt-4o", help="Model to use (default: gpt-4o)") + parser.add_argument("--experiment", metavar="NAME", help="Run as a named Langfuse experiment") args = parser.parse_args() - if args.compare: - asyncio.run(main(args.compare[0], args.compare[1])) + if args.experiment: + asyncio.run(run_experiment(args.model, args.experiment)) + elif args.compare: + asyncio.run(eyeball(args.compare[0], args.compare[1])) else: - asyncio.run(main(args.model, None)) + asyncio.run(eyeball(args.model)) diff --git a/pyproject.toml b/pyproject.toml index 6cfee115..5efb320a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "uvicorn>=0.30.6", "aiohttp>=3.11.14", "posthog>=7.14", + "langfuse>=2,<3", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 77b8dd75..0b8a2c61 100644 --- a/uv.lock +++ b/uv.lock @@ -1331,6 +1331,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, ] +[[package]] +name = "langfuse" +version = "2.60.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "idna" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/45/77fdf53c9e9f49bb78f72eba3f992f2f3d8343e05976aabfe1fca276a640/langfuse-2.60.10.tar.gz", hash = "sha256:a26d0d927a28ee01b2d12bb5b862590b643cc4e60a28de6e2b0c2cfff5dbfc6a", size = 152648, upload-time = "2025-09-16T15:08:12.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/69/08584fbd69e14398d3932a77d0c8d7e20389da3e6470210d6719afba2801/langfuse-2.60.10-py3-none-any.whl", hash = "sha256:815c6369194aa5b2a24f88eb9952f7c3fc863272c41e90642a71f3bc76f4a11f", size = 275568, upload-time = "2025-09-16T15:08:10.166Z" }, +] + [[package]] name = "lark" version = "1.2.2" @@ -2852,6 +2871,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "writing-tools" version = "0.1.0" @@ -2860,6 +2938,7 @@ dependencies = [ { name = "aiohttp" }, { name = "fastapi" }, { name = "gunicorn" }, + { name = "langfuse" }, { name = "openai" }, { name = "posthog" }, { name = "python-dotenv" }, @@ -2891,6 +2970,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.14" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "langfuse", specifier = ">=2,<3" }, { name = "openai", specifier = ">=1.108" }, { name = "posthog", specifier = ">=7.14" }, { name = "python-dotenv", specifier = ">=1.2.2" },