Skip to content
Open
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
10 changes: 8 additions & 2 deletions plugin/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,17 @@ export const runPipeline = async ({
}),
})

const data = await readApiResponse<PipelineRunResponse | { detail?: string }>(response, url)
const data = await readApiResponse<PipelineRunResponse | { detail?: unknown }>(response, url)

if (!response.ok) {
if (response.status == 422) {
throw new Error(
'Plugin is not configured. Set the model, API key, and API URL in the plugin settings.',
)
}
const detail = 'detail' in data ? data.detail : undefined
throw new Error(
'detail' in data && data.detail ? data.detail : 'Pipeline execution failed.',
typeof detail === 'string' && detail ? detail : 'Pipeline execution failed.',
)
}

Expand Down
44 changes: 14 additions & 30 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,6 @@ Create `config.yaml` with your LLM credentials (see `config.template.yaml`). All
prompts file, can be configured there. To use a different config file, set `AI_DOCUMENT_PLUGIN_CONFIG_PATH` to its path
before starting the API or CLI.

Run the full pipeline:

```bash
uv run ai-document-pipeline \
--questionnaire-uuid <QUESTIONNAIRE_UUID> \
--token <TOKEN> \
--template-uuid <TEMPLATE_UUID>
```

The CLI reads technical configuration such as DSW API base URL, database connection, model configuration, prompt paths,
and output file paths from `config.yaml` or from the file referenced by `AI_DOCUMENT_PLUGIN_CONFIG_PATH`. The runtime
inputs `questionnaire_uuid`, `token`, and `template_uuid` are passed explicitly through CLI arguments.

This produces:

| File | Description |
|----------------------------|--------------------------------------------------|
| `dmp_output_pre_polish.md` | Generated DMP with debug tables before polishing |
| `dmp_output.md` | Final polished DMP with token-usage summary |

Step 1 produces new record in the `assignments` table in the DB, when questions are assigned for the first time.

## Pipeline steps

`src/ai_document_plugin_service/run_pipeline.py` orchestrates three steps, each backed by a dedicated module:
Expand All @@ -55,12 +33,12 @@ consolidating duplicates, and improving flow — without adding new content.

## Configuration

The LLM API information (url, api key, model) must be set in the plugin configuration in the deployed DSW instance
(Administration tab > Settings > Plugins).

### `config.yaml`

LLM connection settings (API key, base URL, model name), DSW API base URL, database connection, and all pipeline file
paths. The API key supports environment variable expansion (e.g. `$OPENAI_API_KEY`). The file itself can be overridden
globally via `AI_DOCUMENT_PLUGIN_CONFIG_PATH`. The config file does not contain its own `config_path`; the active file
is determined only by runtime startup/defaults.
The file path can be overridden globally via `AI_DOCUMENT_PLUGIN_CONFIG_PATH`. See `config.template.yaml` for more info.

### `prompts.yaml`

Expand All @@ -79,7 +57,8 @@ The project `Makefile` provides a few shortcuts for common development tasks:
- `make dev` starts the FastAPI development server with auto-reload on port `8010`
- `make build` builds the Python package
- `make db` starts the local PostgreSQL container defined in `docker-compose.yml`
- `make db-init` starts the local PostgreSQL container, waits for it to become healthy, and applies all Alembic migrations
- `make db-init` starts the local PostgreSQL container, waits for it to become healthy, and applies all Alembic
migrations
- `make db-migrate` applies all Alembic migrations to the configured database
- `make db-current` shows the current Alembic revision stored in the database
- `make db-history` shows available Alembic migration history
Expand All @@ -95,13 +74,18 @@ make dev
```

`docker compose` creates the PostgreSQL database itself from the `POSTGRES_DB`, `POSTGRES_USER`, and
`POSTGRES_PASSWORD` values in [docker-compose.yml](/Users/hana/DSW/AI-playground/ai-document-plugin/service/docker-compose.yml:1). On application startup, the service verifies that it can connect to the configured database and runs Alembic migrations to `head`. Alembic creates or updates the schema inside that database; it does not create the PostgreSQL server or database on its own.
`POSTGRES_PASSWORD` values
in [docker-compose.yml](/Users/hana/DSW/AI-playground/ai-document-plugin/service/docker-compose.yml:1). On application
startup, the service verifies that it can connect to the configured database and runs Alembic migrations to `head`.
Alembic creates or updates the schema inside that database; it does not create the PostgreSQL server or database on its
own.

`make db-init` and `make db-migrate` remain useful local shortcuts when you want to manage migrations manually, but they
are not required by deployed service startup.

If the PostgreSQL container already exists with an older Docker volume, the database may be missing even though the server is running. In that case `make db-init` also checks that `ai_document_plugin` exists and creates it before running Alembic migrations.

If the PostgreSQL container already exists with an older Docker volume, the database may be missing even though the
server is running. In that case `make db-init` also checks that `ai_document_plugin` exists and creates it before
running Alembic migrations.

## Tests

Expand Down
17 changes: 6 additions & 11 deletions service/config.template.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
llm_response_generation:
Comment thread
MatejFrnka marked this conversation as resolved.
type: "openai-api"
model: "gpt-oss-120b"
api_url: https://llm.ai.e-infra.cz/v1/
api_key: "xxx"
workers: 4

dsw:
api_url: ""

# Specify urls which can be used for authenticating the user. The user will send the url in a header 'X-Dsw-Api-Url'
# Use the base url for dsw api, not the frontend.
# For example: https://researchers.dsw.elixir-europe.org/wizard-api
auth:
allowed_project_urls:
- "https://your-dsw-instance.example.com"
- "https://your-dsw-instance.example.com/wizard-api"

logging:
level: "INFO"

# Specify login details for Postgresql database.
database:
host: "localhost"
port: "5432"
Expand All @@ -23,5 +17,6 @@ database:
password: "ai_document_plugin"
schema: "public"

# Path to prompts config. Path is set as the relative path from this config.
files:
prompts_path: "prompts.yaml"
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from ai_document_plugin_service.ai.assignment.types import SectionAssignment
from ai_document_plugin_service.ai.common.config import Config
from ai_document_plugin_service.ai.common.llm_client import LLMClient
from ai_document_plugin_service.ai.common.progress import progress_percent
from ai_document_plugin_service.ai.common.types import AssignmentStats
from ai_document_plugin_service.ai.knowledgemodel.types import QuestionData
Expand All @@ -38,6 +39,17 @@ class AssignmentComponentResult(TypedDict):

@component
class AssignmentComponent:
"""
This component is responsible for assigning questions from the questionnaire to the sections from the dmp template.
For example, it assigns question 'When will the project start?' to sections Introduction and Project Timeline
"""

def __init__(self, llm_client: LLMClient, config: Config) -> None:
self.llm_client = llm_client
self.config = config
self.section_id_generator = OpenAISectionIdGenerator(llm_client, config)
self.section_matcher = OpenAILayerMatcher(self.llm_client, self.config)

@staticmethod
def _add_chunk_mapping_to_result(
*,
Expand All @@ -53,16 +65,13 @@ def _add_chunk_mapping_to_result(
continue
result_mapping[question_path] = [section_formatter.record_id_for_sid(sid) for sid in section_ids]

@staticmethod
def _match_single_chunk(
*,
config: Config,
self,
sections_xml: str,
question_chunk: str,
stats: AssignmentStats,
) -> dict[str, list[str]]:
matcher = OpenAILayerMatcher(config)
return matcher.match_questions_to_sections(
return self.section_matcher.match_questions_to_sections(
sections_xml,
question_chunk,
stats,
Expand All @@ -73,7 +82,6 @@ def run(
self,
data: list[QuestionData],
template_data: dict[str, Any],
config: Config,
km: dict[str, Any],
on_progress: Callable[[str], None] | None = None,
) -> AssignmentComponentResult:
Expand All @@ -85,7 +93,7 @@ def run(
stats = AssignmentStats()

section_formatter = SectionFormatter(sections)
section_formatter.create_mappings(OpenAISectionIdGenerator(config), stats)
section_formatter.create_mappings(self.section_id_generator, stats)
sections_xml = section_formatter.get_sections_as_xml()

result_mapping = {}
Expand All @@ -94,7 +102,6 @@ def run(

def match_chunk(question_chunk: str) -> dict[str, list[str]]:
result = self._match_single_chunk(
config=config,
sections_xml=sections_xml,
question_chunk=question_chunk,
stats=stats,
Expand All @@ -109,8 +116,8 @@ def match_chunk(question_chunk: str) -> dict[str, list[str]]:
for question_to_section_ids in thread_map(
match_chunk,
question_chunks,
max_workers=config.parallel_workers,
desc=f'Assigning questions to sections ({config.parallel_workers} workers)',
max_workers=self.llm_client.get_max_workers(),
desc=f'Assigning questions to sections ({self.llm_client.get_max_workers()} workers)',
):
self._add_chunk_mapping_to_result(
result_mapping=result_mapping,
Expand Down
5 changes: 2 additions & 3 deletions service/src/ai_document_plugin_service/ai/assignment/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ def match_questions_to_sections(


class OpenAILayerMatcher(LayerMatcher):
def __init__(self, config: Config) -> None:
def __init__(self, llm_client: LLMClient, config: Config) -> None:
self.client = llm_client
self.config = config
self.client = LLMClient(config)

def match_questions_to_sections(
self,
Expand All @@ -74,7 +74,6 @@ def match_questions_to_sections(

def call_and_parse() -> dict[str, list[str]]:
response = self.client.completion(
model=self.config.model,
messages=messages,
temperature=self.config.assignment.temperature,
max_tokens=self.config.assignment.max_tokens,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import typing
from abc import ABC, abstractmethod

from openai import OpenAI
from tqdm import tqdm

from ai_document_plugin_service.ai.assignment.types import LeafSection
Expand Down Expand Up @@ -29,9 +28,9 @@ def generate_leaf_section_ids(


class OpenAISectionIdGenerator(SectionIdGenerator):
def __init__(self, config: Config) -> None:
def __init__(self, llm_client: LLMClient, config: Config) -> None:
self.config = config
self.client = LLMClient(config)
self.client = llm_client

@typing.override
def generate_leaf_section_ids(
Expand Down Expand Up @@ -59,7 +58,6 @@ def generate_leaf_section_ids(
)
response = call_with_retry(
lambda um=user_msg: self.client.completion(
model=self.config.model,
messages=[
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': um},
Expand Down Expand Up @@ -92,7 +90,6 @@ def _normalize_section_id(raw: str) -> str:
class LoggingNoopSectionIdGenerator(SectionIdGenerator):
def __init__(self, config: Config) -> None:
self.config = config
self.client = OpenAI(api_key=config.api_key, base_url=config.api_url)

@typing.override
def generate_leaf_section_ids( # ty: ignore[invalid-method-override]
Expand Down
48 changes: 5 additions & 43 deletions service/src/ai_document_plugin_service/ai/common/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
import pathlib
from dataclasses import dataclass, replace
from dataclasses import dataclass
from typing import Any

import yaml
Expand Down Expand Up @@ -41,26 +41,21 @@ class DatabaseConfig:

@dataclass(frozen=True)
class Config:
api_key: str
api_url: str
dsw_api_url: str
allowed_project_urls: tuple[str, ...]
model: str
log_level: str
database: DatabaseConfig
files: FilePaths
assignment: SystemAndUserPrompt
section_id: SystemAndUserPrompt
dmp_generation: SystemPrompt
dmp_polishing: SystemAndUserPrompt
parallel_workers: int


@dataclass(frozen=True)
class LLMConfigOverride:
model: str | None = None
api_key: str | None = None
api_url: str | None = None
class LLMConfig:
model: str
api_key: str
api_url: str
parallel_workers: int | None = None


Expand Down Expand Up @@ -134,19 +129,6 @@ def _get_allowed_project_urls(config: dict[str, Any]) -> tuple[str, ...]:
return tuple(normalized)


def _get_parallel_workers(config: dict[str, Any]) -> int:
workers = config.get('llm_response_generation', {}).get('workers', 1)
try:
workers_int = int(workers)
except (TypeError, ValueError) as exc:
msg = "Invalid config value: 'parallelism.workers' must be an integer >= 1"
raise ValueError(msg) from exc
if workers_int < 1:
msg = "Invalid config value: 'parallelism.workers' must be >= 1"
raise ValueError(msg)
return workers_int


def _resolve_existing_path(path: str, *, base_dir: pathlib.Path | None = None) -> str:
normalized_path = _normalize_path(path)
path_obj = pathlib.Path(normalized_path)
Expand Down Expand Up @@ -199,12 +181,6 @@ def load_config(config_path: str | None = None) -> Config:
raise TypeError(msg)

return Config(
api_key=_expand_env_vars(
_get(config, 'llm_response_generation', 'api_key'),
),
api_url=_get(config, 'llm_response_generation', 'api_url'),
model=_get(config, 'llm_response_generation', 'model'),
dsw_api_url=_get(config, 'dsw', 'api_url'),
allowed_project_urls=_get_allowed_project_urls(config),
log_level=_get_log_level(config),
database=DatabaseConfig(
Expand Down Expand Up @@ -241,18 +217,4 @@ def load_config(config_path: str | None = None) -> Config:
system_message=_get(prompts, 'dmp_polishing', 'system_message'),
user_message=_get(prompts, 'dmp_polishing', 'user_message'),
),
parallel_workers=_get_parallel_workers(config),
)


def apply_llm_override(config: Config, override: LLMConfigOverride | None = None) -> Config:
if override is None:
return config

return replace(
config,
model=override.model or config.model,
api_key=override.api_key or config.api_key,
api_url=override.api_url or config.api_url,
parallel_workers=override.parallel_workers or config.parallel_workers,
)
Loading