File size: 7,065 Bytes
f4d07f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Gradio app for the ROS 2 coding assistant.

Deployed at https://huggingface.co/spaces/eoinedge/ros2 and runnable locally
with `python app.py`.

Retrieval and generation are separate tabs on purpose. Retrieval alone is fast,
runs on CPU, and answers "where is this documented?" — which is often the whole
question. Generation is opt-in because on a free CPU Space it is slow, and a
slow wrong answer is worse than a fast citation.
"""

from __future__ import annotations

import json
import os
import sys
from functools import lru_cache
from pathlib import Path

import gradio as gr

sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts"))
import retrieval  # noqa: E402
import source_config as cfg  # noqa: E402

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

ROOT = Path(__file__).resolve().parent
INDEX_DIR = Path(os.environ.get("ROS2_INDEX", ROOT / "data" / "index"))
GEN_MODEL = os.environ.get("ROS2_GEN_MODEL", "Qwen/Qwen2.5-Coder-1.5B-Instruct")
ENABLE_GENERATION = os.environ.get("ROS2_ENABLE_GENERATION", "0") == "1"

SYSTEM_PROMPT = (
    "You are a ROS 2 coding assistant. Answer only from the documentation excerpts "
    "provided. ROS 2 changes across distributions, and an answer that was right for "
    "Foxy can be wrong for Rolling - if the excerpts do not cover the question, say so "
    "and name what is missing rather than filling the gap from ROS 1 habits or general "
    "robotics knowledge. Cite the source path for anything you assert, and say which "
    "distribution an answer applies to when the excerpts make that clear. Show things "
    "as they would actually be written: a package.xml entry, a CMakeLists block, a "
    "ros2 or colcon command, a QoS profile."
)

EXAMPLES = cfg.EXAMPLES


def resources():
    return retrieval.load(str(INDEX_DIR))


def search(question: str, k: int) -> list[dict]:
    """Shared with the CLI via scripts/retrieval.py — same index, same
    re-ranking, so a result here matches a result from `scripts/ask.py`."""
    try:
        return retrieval.search(question, int(k), INDEX_DIR)
    except FileNotFoundError as error:
        raise gr.Error(str(error)) from error


def render(hits: list[dict]) -> str:
    if not hits:
        return "Nothing retrieved."
    blocks = ["### Retrieved passages\n"]
    for position, hit in enumerate(hits, start=1):
        blocks.append(
            f"**{position}. [{hit['source']}]({hit['url']})** — {hit['section']}  \n"
            f"`similarity {hit['score']:.3f}`\n\n"
            f"```\n{hit['text'][:900]}\n```\n"
        )
    return "\n".join(blocks)


def do_retrieve(question: str, k: int) -> str:
    if not question.strip():
        return "Ask something about ROS 2."
    return render(search(question, k))


@lru_cache(maxsize=1)
def generator():
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained(GEN_MODEL)
    model = AutoModelForCausalLM.from_pretrained(
        GEN_MODEL,
        torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
        device_map="auto" if torch.cuda.is_available() else None,
    )
    return tokenizer, model


def do_answer(question: str, k: int) -> tuple[str, str]:
    if not question.strip():
        return "Ask something about ROS 2.", ""
    hits = search(question, k)
    if not hits:
        return "Nothing retrieved — the index may not cover this.", ""
    if not ENABLE_GENERATION:
        return (
            "Generation is off on this Space (CPU hardware makes it slow enough to be "
            "misleading). The retrieved passages below are the grounded answer; set "
            "`ROS2_ENABLE_GENERATION=1` to turn the model on locally or on GPU "
            "hardware.",
            render(hits),
        )

    tokenizer, model = generator()
    context = "\n\n".join(
        f"[{index + 1}] {hit['source']} - {hit['section']}\n{hit['text']}"
        for index, hit in enumerate(hits)
    )
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": f"Documentation excerpts:\n\n{context}\n\nQuestion: {question}"},
    ]
    prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    output = model.generate(
        **inputs, max_new_tokens=512, do_sample=False, pad_token_id=tokenizer.eos_token_id
    )
    answer = tokenizer.decode(
        output[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True
    ).strip()
    return answer, render(hits)


def corpus_note() -> str:
    try:
        _, _, _, meta = resources()
    except Exception:
        return "Index not built yet."
    source = str(meta.get("source", "")).replace("\n", " · ").strip(" ·")
    return (
        f"**{meta['chunks']:,} chunks** from **{meta['documents']} documents** · "
        f"embeddings `{meta['embedding_model']}` · {meta['index']}  \n"
        f"<sub>{source}</sub>"
    )


with gr.Blocks(title="ROS 2 Coding Assistant", theme=gr.themes.Soft()) as demo:
    gr.Markdown("# ROS 2 coding assistant")
    gr.Markdown(
        "Retrieval over the ROS 2 documentation source, built from the RST in "
        "`ros2/ros2_documentation` (rolling). Every passage links back to the page it "
        "came from, so an answer can always be checked against the docs.\n\n"
        "Companion build for Zephyr RTOS: "
        "[eoinedge/zephyrproject-rag](https://huggingface.co/spaces/eoinedge/zephyrproject-rag)."
    )
    gr.Markdown(corpus_note())

    with gr.Tab("Ask"):
        question = gr.Textbox(label="Question", placeholder=EXAMPLES[0], lines=2)
        with gr.Row():
            top_k = gr.Slider(1, 12, value=6, step=1, label="Passages to retrieve")
            ask_button = gr.Button("Ask", variant="primary")
        answer_box = gr.Markdown(label="Answer")
        sources_box = gr.Markdown(label="Sources")
        gr.Examples(examples=EXAMPLES, inputs=question)
        ask_button.click(do_answer, [question, top_k], [answer_box, sources_box])
        question.submit(do_answer, [question, top_k], [answer_box, sources_box])

    with gr.Tab("Search only"):
        gr.Markdown(
            "Retrieval with no model in the loop. Fast, and often the whole answer — "
            "the passage you need with a link to the page."
        )
        search_question = gr.Textbox(label="Question", lines=2)
        search_k = gr.Slider(1, 20, value=8, step=1, label="Passages")
        search_button = gr.Button("Search", variant="primary")
        search_output = gr.Markdown()
        search_button.click(do_retrieve, [search_question, search_k], search_output)
        search_question.submit(do_retrieve, [search_question, search_k], search_output)


if __name__ == "__main__":
    demo.launch(
        server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
        server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
    )