Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ ASWF projects. The DNA TSC, when fully formed, can revise this all as needed.
Communications
--------------

* [ASWF Slack](https://slack.aswf.io) -- join for the `#wg-ml` channel for the discussions about this project.
* [ASWF Slack](https://slack.aswf.io) -- join for the `#wg-ml` channel for the discussions about machine learning and the `#dna` and `##dailies-notes-assistant-tech` channels for the discussions about Dailies Notes Assistant.
* Weekly Technical Steering Committee (TSC) Zoom meetings are currently Mondays at 12:00 PT (requests to change the day or time will be entertained if it's impeding participation of stakeholders).


Contributor License Agreement (CLA) and Intellectual Property
-------------------------------------------------------------

We don't yet have a CLA in place. TBD.
### CLA

All contributors must sign the ASWF Contributor License Agreement (CLA). This can be found on your first pull request to the repository.

### DCO contribution sign off

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ DNA is built for:
- Submit a Pull Request: [**https://github.com/AcademySoftwareFoundation/dna/pulls**](https://github.com/AcademySoftwareFoundation/dna/pulls)
- [GitHub project page](https://github.com/AcademySoftwareFoundation/dna)
- [The DNA project was established by this proposal](https://github.com/AcademySoftwareFoundation/tac/issues/1040)
- [ASWF's Machine Learning Working Group proposal](https://github.com/AcademySoftwareFoundation/tac/issues/1029) -- describes the purpose and scope of MLWG. Join the [ASWF Slack](https://slack.aswf.io/) -- join for the #wg-ml channel for the discussions about Machine Learning
- [ASWF's Machine Learning Working Group proposal](https://github.com/AcademySoftwareFoundation/tac/issues/1029) -- describes the purpose and scope of MLWG. Join the [ASWF Slack](https://slack.aswf.io/) -- join for the #wg-ml, #dna, and ##dailies-notes-assistant-tech channels for the discussions about Machine Learning and Dailies Notes Assistant

## ☎️ Contributing and Developer Documentation

Expand Down
9 changes: 5 additions & 4 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
pydantic==2.13.4
pydantic-settings==2.8.1
instructor==1.15.1
pytest==7.4.3
pytest-cov==4.1.0
pytest-asyncio==0.21.1
httpx==0.25.2
shotgun_api3==3.9.2
pymongo==4.10.1
websockets==12.0
openai==1.58.1
openai==2.36.0
google-auth==2.0.0
requests==2.28.0
requests==2.32.3
python-multipart==0.0.9
PyYAML==6.0.1
212 changes: 211 additions & 1 deletion backend/src/dna/llm_providers/llm_provider_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,94 @@
Abstract base class for LLM providers and factory function.
"""

import json
import logging
import os
from typing import Optional
from typing import Any, Awaitable, Callable, Optional, TypeVar

import instructor
from openai import AsyncOpenAI
from pydantic import BaseModel

from dna.prompts.generate_note_prompt import GENERATE_NOTE_PROMPT

logger = logging.getLogger(__name__)

T = TypeVar("T", bound=BaseModel)

DEFAULT_MAX_TOOL_RESULT_CHARS = 50_000


def _truncate_tool_result(content: str, max_chars: int) -> str:
if len(content) <= max_chars:
return content
suffix = "\n...[truncated]"
return content[: max_chars - len(suffix)] + suffix


def _safe_parse_tool_arguments(
raw: str | None,
) -> tuple[dict[str, Any] | None, str | None]:
text = (raw or "").strip() or "{}"
try:
parsed = json.loads(text)
except json.JSONDecodeError as exc:
err = json.dumps(
{
"error": "Invalid JSON in tool arguments.",
"detail": str(exc),
}
)
return None, err
if not isinstance(parsed, dict):
return None, json.dumps({"error": "Tool arguments must be a JSON object."})
return parsed, None


def _assistant_message_with_tool_calls(
msg: Any, tool_calls: list[Any]
) -> dict[str, Any]:
return {
"role": "assistant",
"content": msg.content,
"tool_calls": [
{
"id": tc.id,
"type": getattr(tc, "type", "function") or "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments or "{}",
},
}
for tc in tool_calls
],
}


async def _append_tool_use_round(
messages: list[dict[str, Any]],
msg: Any,
tool_calls: list[Any],
tool_executor: Callable[[str, dict[str, Any]], Awaitable[str]],
max_tool_result_chars: int,
) -> None:
messages.append(_assistant_message_with_tool_calls(msg, tool_calls))
for tc in tool_calls:
args, parse_error = _safe_parse_tool_arguments(tc.function.arguments)
if parse_error is not None:
result = parse_error
else:
assert args is not None
result = await tool_executor(tc.function.name, args)
result = _truncate_tool_result(result, max_tool_result_chars)
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": result,
}
)


class LLMProviderBase:
"""Abstract base class for LLM providers."""
Expand Down Expand Up @@ -119,6 +200,135 @@ async def generate_note(

return response.choices[0].message.content or ""

async def generate_with_tools(
self,
system_prompt: str,
user_message: str,
tools: list[dict[str, Any]],
tool_executor: Callable[[str, dict[str, Any]], Awaitable[str]],
max_iterations: int = 5,
temperature: float = 0.2,
max_tool_result_chars: int = DEFAULT_MAX_TOOL_RESULT_CHARS,
) -> str:
"""Run an agentic loop: LLM may call tools until it returns final text."""
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
last_text = ""
for _ in range(max_iterations):
response = await self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools,
tool_choice="auto",
temperature=temperature,
max_tokens=2048,
)
choice = response.choices[0]
msg = choice.message
last_text = msg.content or ""
tool_calls = getattr(msg, "tool_calls", None) or []
if tool_calls:
await _append_tool_use_round(
messages,
msg,
tool_calls,
tool_executor,
max_tool_result_chars,
)
else:
return last_text
return last_text

async def generate_structured_with_tools(
self,
system_prompt: str,
user_message: str,
tools: list[dict[str, Any]],
tool_executor: Callable[[str, dict[str, Any]], Awaitable[str]],
response_model: type[T],
max_iterations: int = 5,
temperature: float = 0.2,
max_tool_result_chars: int = DEFAULT_MAX_TOOL_RESULT_CHARS,
) -> T:
"""Tool-use phase then instructor-validated structured extraction."""
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
hit_limit_with_tools = False
for i in range(max_iterations):
response = await self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools,
tool_choice="auto",
temperature=temperature,
max_tokens=2048,
)
choice = response.choices[0]
msg = choice.message
tool_calls = getattr(msg, "tool_calls", None) or []
if tool_calls:
await _append_tool_use_round(
messages,
msg,
tool_calls,
tool_executor,
max_tool_result_chars,
)
if i == max_iterations - 1:
hit_limit_with_tools = True
else:
messages.append(
{"role": "assistant", "content": msg.content or ""},
)
hit_limit_with_tools = False
break

if hit_limit_with_tools:
logger.warning(
"Tool-use loop reached max_iterations=%s with pending tool rounds; "
"requesting a final assistant message with tool_choice=none before "
"structured extraction.",
max_iterations,
)
response = await self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools,
tool_choice="none",
temperature=temperature,
max_tokens=2048,
)
final_msg = response.choices[0].message
messages.append(
{"role": "assistant", "content": final_msg.content or ""},
)

extraction_messages = list(messages)
extraction_messages.append(
{
"role": "user",
"content": (
"Provide your final quality-check result for this draft and check. "
"Fill every required field in the structured response schema."
),
}
)
instructor_client = instructor.from_openai(
self.client,
mode=instructor.Mode.JSON,
)
return await instructor_client.chat.completions.create(
model=self.model,
messages=extraction_messages,
response_model=response_model,
temperature=temperature,
max_tokens=2048,
)


def get_llm_provider() -> LLMProviderBase:
"""Factory function to get the configured LLM provider."""
Expand Down
22 changes: 22 additions & 0 deletions backend/src/dna/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
PlaylistMetadata,
PlaylistMetadataUpdate,
)
from dna.models.qc_check import (
DEFAULT_ACTION_ITEM_CHECK,
NoteQCAttributeSuggestion,
NoteQCCheck,
NoteQCCheckCreate,
NoteQCCheckUpdate,
NoteQCLLMOutput,
NoteQCResult,
NoteQCSeverity,
RunQCChecksRequest,
RunQCChecksResponse,
)
from dna.models.requests import (
CreateNoteRequest,
EntityLink,
Expand Down Expand Up @@ -103,4 +115,14 @@
"UserSettings",
"UserSettingsUpdate",
"UserSettingsResponse",
"NoteQCSeverity",
"NoteQCCheckCreate",
"NoteQCCheckUpdate",
"NoteQCCheck",
"NoteQCAttributeSuggestion",
"NoteQCLLMOutput",
"NoteQCResult",
"RunQCChecksRequest",
"RunQCChecksResponse",
"DEFAULT_ACTION_ITEM_CHECK",
]
Loading
Loading