raft-ft 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
raft/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """RAFT: Retrieval-Augmented Fine-Tuning."""
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ __version__ = "2.2.0"
raft/chunker.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ This module contains the chunker function, which splits blog posts into chunks
3
+ of a maximum length.
4
+ """
5
+
6
+ import json
7
+ from typing import List, Dict, Tuple, Generator
8
+ import tiktoken
9
+ import pandas as pd
10
+
11
+ MAX_EMBEDDING_LENGTH = 2048
12
+ encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
13
+
14
+
15
+ def map_line(line: str) -> Tuple[str, str, int]:
16
+ """
17
+ Encode and potentially truncate a line of text.
18
+
19
+ Args:
20
+ line (str): The input line of text.
21
+
22
+ Returns:
23
+ Tuple[str, str, int]: A tuple containing:
24
+ - the original or truncated line,
25
+ - the decoded version, and
26
+ - the length of the encoded line.
27
+ """
28
+ encoded = encoding.encode(line)
29
+ if len(encoded) < MAX_EMBEDDING_LENGTH:
30
+ return (line, encoding.decode(encoded), len(encoded))
31
+ else:
32
+ truncated_encoded = encoded[:MAX_EMBEDDING_LENGTH]
33
+ return (
34
+ line[:MAX_EMBEDDING_LENGTH],
35
+ encoding.decode(truncated_encoded),
36
+ MAX_EMBEDDING_LENGTH,
37
+ )
38
+
39
+
40
+ def split_into_chunks(
41
+ blog_posts: pd.DataFrame,
42
+ ) -> Generator[Tuple[Dict[str, str], str], None, None]:
43
+ """
44
+ Split blog posts into chunks of a maximum length.
45
+
46
+ Args:
47
+ blog_posts (pd.DataFrame): DataFrame containing blog post data.
48
+
49
+ Yields:
50
+ Tuple[Dict[str, str], str]: A tuple containing metadata
51
+ and the chunked content.
52
+ """
53
+ for _, post in blog_posts.iterrows():
54
+ print(f"Splitting {post['title']}")
55
+ lines = list(map(map_line, post["content"].split("\n")))
56
+
57
+ # Calculate total parts
58
+ total_parts = 0
59
+ temp_chunk_length = 0
60
+ for _, _, length in lines:
61
+ if temp_chunk_length + length > MAX_EMBEDDING_LENGTH:
62
+ total_parts += 1
63
+ temp_chunk_length = length
64
+ else:
65
+ temp_chunk_length += length
66
+ total_parts += 1 # for the last chunk
67
+
68
+ chunk: List[str] = []
69
+ chunk_length = 0
70
+ part = 1
71
+
72
+ for line, _, length in lines:
73
+ if chunk_length + length > MAX_EMBEDDING_LENGTH:
74
+ metadata = {
75
+ "title": post["title"],
76
+ "url": post["link"],
77
+ "date": (
78
+ post["date"].isoformat()
79
+ if isinstance(post["date"], pd.Timestamp)
80
+ else post["date"]
81
+ ),
82
+ "total_parts": str(total_parts),
83
+ "part": str(part),
84
+ }
85
+ yield metadata, "\n".join(chunk)
86
+ chunk = []
87
+ chunk_length = 0
88
+ part += 1
89
+ chunk.append(line)
90
+ chunk_length += length
91
+
92
+ if chunk:
93
+ metadata = {
94
+ "title": post["title"],
95
+ "url": post["link"],
96
+ "date": (
97
+ post["date"].isoformat()
98
+ if isinstance(post["date"], pd.Timestamp)
99
+ else post["date"]
100
+ ),
101
+ "total_parts": str(total_parts),
102
+ "part": str(part),
103
+ }
104
+ yield metadata, "\n".join(chunk)
105
+
106
+
107
+ def chunker(name: str) -> None:
108
+ """
109
+ Process a JSONL file of blog posts, split them into chunks,
110
+ and save the results.
111
+
112
+ Args:
113
+ name (str): The name of the file to process (without extension).
114
+ """
115
+ sourcefile = f"data/{name}.jsonl"
116
+ outputfile = f"data/{name}_chunked.jsonl"
117
+
118
+ try:
119
+ # Read the JSONL file line by line
120
+ with open(sourcefile, "r") as f:
121
+ data = [json.loads(line) for line in f]
122
+
123
+ blog_posts: pd.DataFrame = pd.DataFrame(data)
124
+ print(f"Splitting {len(blog_posts)} blog posts into chunks")
125
+
126
+ with open(outputfile, "w") as f:
127
+ for item in split_into_chunks(blog_posts):
128
+ f.write(json.dumps(item) + "\n")
129
+
130
+ except json.JSONDecodeError as e:
131
+ print(f"Error decoding JSON: {e}")
132
+ print(f"Error occurred at line {e.lineno}")
133
+ with open(sourcefile, "r") as f:
134
+ problematic_line = f.readlines()[e.lineno - 1]
135
+ print(f"Problematic line: {problematic_line}")
136
+
137
+ except Exception as e:
138
+ print(f"An error occurred: {e}")
raft/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ This module contains the main CLI functionality for the RAFT project.
3
+ """
4
+
5
+ import argparse
6
+ from raft import (
7
+ files_helper,
8
+ embeddings_helpers,
9
+ substack_embeddings,
10
+ generate_finetune,
11
+ oai_finetune,
12
+ memories,
13
+ )
14
+
15
+
16
+ def action_doc() -> str:
17
+ """
18
+ Return a string describing available actions for the CLI.
19
+
20
+ Returns:
21
+ str: Description of available actions.
22
+ """
23
+ return """
24
+ The following actions are available:
25
+
26
+ - interactive: Guided end-to-end session: sources, conversations, finetune.
27
+ - tweets: Build a dataset from tweets via ariadne interactive.
28
+ - fetch: Fetch the blog from Substack and store it in the data directory.
29
+ - chunk: Chunk the blog into 4096 token pieces and store them in /data.
30
+ - embed: Create embeddings for the chunks and store them.
31
+ - ft:gen: Generate finetune files for the blog.
32
+ - ft:run: Run the finetune job (OpenAI, or huggingface via opbdh).
33
+ - bench:setup: Setup the benchmark for the blog.
34
+ - ask: Ask a question about the blog content.
35
+
36
+ ft:run routes by --model: OpenAI-finetunable ids go to the OpenAI API,
37
+ anything else (an org/name huggingface id) is trained on a GPU pod via
38
+ opbdh; unknown flags after the name are forwarded verbatim to
39
+ `opbdh launch` (e.g. --vram-gb 48 --max-spend 5).
40
+ """
41
+
42
+
43
+ cmds = [
44
+ "interactive",
45
+ "tweets",
46
+ "fetch",
47
+ "chunk",
48
+ "embed",
49
+ "ft:gen",
50
+ "ft:run",
51
+ "bench:setup",
52
+ "ask",
53
+ ]
54
+
55
+
56
+ def main() -> None:
57
+ """
58
+ Main function to handle CLI arguments and execute corresponding actions.
59
+ """
60
+ parser = argparse.ArgumentParser(
61
+ description="Run the raft command.",
62
+ formatter_class=argparse.RawDescriptionHelpFormatter,
63
+ epilog=action_doc(),
64
+ )
65
+ parser.add_argument(
66
+ "action",
67
+ help="The action to perform; see below for details.",
68
+ choices=cmds,
69
+ )
70
+
71
+ parser.add_argument(
72
+ "name",
73
+ nargs="?",
74
+ default="",
75
+ help="The name of the dataset to process.",
76
+ )
77
+ parser.add_argument(
78
+ "--oai",
79
+ help="Only generate finetune or benchmark for openai \
80
+ (from existing generic file).",
81
+ default=False,
82
+ action="store_true",
83
+ )
84
+ parser.add_argument(
85
+ "--generic",
86
+ help="Only generate generic finetune or benchmark file.",
87
+ default=False,
88
+ action="store_true",
89
+ )
90
+ parser.add_argument(
91
+ "--question",
92
+ help="The question to ask about the blog content.",
93
+ default=None,
94
+ )
95
+ parser.add_argument(
96
+ "--no-useful-check",
97
+ action="store_true",
98
+ help="Skip usefulness check when summarizing memories",
99
+ )
100
+ parser.add_argument(
101
+ "--model",
102
+ help="Model to finetune (ft:run): an OpenAI id, or a \
103
+ huggingface org/name id trained via opbdh.",
104
+ default="",
105
+ )
106
+ parser.add_argument(
107
+ "--no-interactive",
108
+ action="store_true",
109
+ help="ft:run: never prompt; rely on flags and opbdh config.",
110
+ )
111
+
112
+ args, extra = parser.parse_known_args()
113
+
114
+ if args.action not in ("ft:run",) and extra:
115
+ parser.error(f"unrecognized arguments: {' '.join(extra)}")
116
+
117
+ needs_name = args.action not in ("interactive", "tweets")
118
+ if needs_name and not args.name:
119
+ parser.error(f"the '{args.action}' action requires a dataset name")
120
+
121
+ if args.action == "interactive":
122
+ from .flows import run_interactive
123
+
124
+ run_interactive()
125
+ elif args.action == "tweets":
126
+ from .tweet_mode import run_tweet_mode
127
+
128
+ run_tweet_mode(args.name)
129
+ elif args.action == "fetch":
130
+ substack_embeddings.main(args.name)
131
+ elif args.action == "chunk":
132
+ files_helper.chunker(args.name)
133
+ elif args.action == "embed":
134
+ embeddings_helpers.store_grounding_embeddings(args.name)
135
+ elif args.action == "ft:gen":
136
+ if args.oai:
137
+ oai_finetune.create_openai_finetune_file(args.name)
138
+ elif args.generic:
139
+ generate_finetune.generate_finetune(args.name)
140
+ else:
141
+ generate_finetune.generate_finetune(args.name)
142
+ oai_finetune.create_openai_finetune_file(args.name)
143
+ elif args.action == "ft:run":
144
+ from .hf_finetune import is_openai_finetunable, run_hf_finetune
145
+ from .interactive import ask
146
+
147
+ model = args.model
148
+ if not model and not args.no_interactive:
149
+ model = ask(
150
+ "Model to finetune (OpenAI id or huggingface org/name)",
151
+ "gpt-4o-mini-2024-07-18",
152
+ )
153
+ if is_openai_finetunable(model):
154
+ if extra:
155
+ parser.error(
156
+ "opbdh flags only apply to huggingface models: "
157
+ f"{' '.join(extra)}"
158
+ )
159
+ oai_finetune.run_oai_finetune(args.name, model=model)
160
+ else:
161
+ run_hf_finetune(
162
+ args.name,
163
+ model,
164
+ opbdh_args=extra,
165
+ interactive=not args.no_interactive,
166
+ )
167
+ elif args.action == "bench:setup":
168
+ if args.oai:
169
+ oai_finetune.create_openai_finetune_file(args.name, "benchmark")
170
+ elif args.generic:
171
+ generate_finetune.generate_finetune(args.name)
172
+ else:
173
+ generate_finetune.generate_benchmark(args.name)
174
+ oai_finetune.create_openai_finetune_file(args.name, "benchmark")
175
+ elif args.action == "ask":
176
+ if args.question is None:
177
+ print("Please provide a question using the --question argument.")
178
+ else:
179
+ memory_manager = memories.MemoryManager(
180
+ args.name, {}
181
+ ) # Empty metadata for now
182
+ answer = memory_manager.ask_question(args.question)
183
+ print(f"Answer: {answer}")
184
+ else:
185
+ print(f"Unknown action: {args.action}")
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
@@ -0,0 +1,338 @@
1
+ """
2
+ Turn conversation examples and text sources -- structured or not -- into
3
+ the datasets raft consumes:
4
+
5
+ - transcripts: data/{name}_transcript_{i}.json
6
+ {"participants": {"q": ..., "a": ...}, "date": ..., "url": ...,
7
+ "exchanges": [[question, answer], ...]}
8
+ - grounding corpus: data/{name}.jsonl, one {"title", "link", "date",
9
+ "content"} object per line (the input of `raft chunk`).
10
+
11
+ Unstructured files are converted with an LLM; structured ones (raft
12
+ transcripts, chat-message arrays, grounding jsonl) are recognised and
13
+ passed through.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+
22
+ from openai import OpenAI
23
+
24
+ from . import hx
25
+
26
+ STRUCTURER_MODEL = os.environ.get("RAFT_LLM_MODEL", "gpt-4o")
27
+
28
+ # Keep single LLM calls comfortably inside the context window.
29
+ MAX_STRUCTURE_CHARS = 24000
30
+
31
+ STRUCTURER_SYSTEM_PROMPT = """\
32
+ You extract question/answer exchanges from a raw conversation record
33
+ (interview, chat log, podcast transcript, email thread...).
34
+
35
+ The target is the person being emulated: their utterances are answers,
36
+ everyone else's are questions. Merge consecutive utterances by the same
37
+ side. Drop utterances that are not part of a question/answer flow
38
+ (stage directions, ads, timestamps).
39
+
40
+ Reply with JSON only, in this exact shape:
41
+ {
42
+ "participants": {"q": "<questioner name>", "a": "<target name>"},
43
+ "date": "<ISO date if inferable, else null>",
44
+ "url": "<source url if present, else null>",
45
+ "exchanges": [["<question>", "<answer>"], ...]
46
+ }"""
47
+
48
+
49
+ def transcript_path(name: str, index: int) -> str:
50
+ """Return the path of transcript #index for a dataset name."""
51
+ return f"data/{name}_transcript_{index}.json"
52
+
53
+
54
+ def next_transcript_index(name: str) -> int:
55
+ """Return the first unused transcript index for a dataset name."""
56
+ i = 1
57
+ while os.path.exists(transcript_path(name, i)):
58
+ i += 1
59
+ return i
60
+
61
+
62
+ def write_transcript(
63
+ name: str,
64
+ participants: Dict[str, str],
65
+ date: str,
66
+ url: str,
67
+ exchanges: List[List[str]],
68
+ index: Optional[int] = None,
69
+ ) -> str:
70
+ """
71
+ Write a transcript file in the format generate_finetune expects.
72
+
73
+ Returns:
74
+ str: The path written.
75
+ """
76
+ if index is None:
77
+ index = next_transcript_index(name)
78
+ path = transcript_path(name, index)
79
+ os.makedirs(os.path.dirname(path), exist_ok=True)
80
+ with open(path, "w") as f:
81
+ json.dump(
82
+ {
83
+ "participants": participants,
84
+ "date": date,
85
+ "url": url,
86
+ "exchanges": exchanges,
87
+ },
88
+ f,
89
+ indent=2,
90
+ )
91
+ return path
92
+
93
+
94
+ def is_transcript(data: Any) -> bool:
95
+ """Check whether parsed JSON already matches the transcript schema."""
96
+ return (
97
+ isinstance(data, dict)
98
+ and isinstance(data.get("exchanges"), list)
99
+ and isinstance(data.get("participants"), dict)
100
+ and {"q", "a"} <= set(data["participants"])
101
+ )
102
+
103
+
104
+ def parse_json_or_jsonl(raw: str) -> Any:
105
+ """
106
+ Parse a string as JSON, falling back to JSONL (returning the list of
107
+ parsed lines). Returns None if it is neither.
108
+ """
109
+ try:
110
+ return json.loads(raw)
111
+ except json.JSONDecodeError:
112
+ pass
113
+ try:
114
+ parsed = [json.loads(line) for line in raw.splitlines() if line.strip()]
115
+ except json.JSONDecodeError:
116
+ return None
117
+ return parsed or None
118
+
119
+
120
+ def is_message_array(value: Any) -> bool:
121
+ """Check whether a value looks like a chat-message array."""
122
+ return (
123
+ isinstance(value, list)
124
+ and bool(value)
125
+ and all(isinstance(m, dict) for m in value)
126
+ and any("role" in m for m in value)
127
+ )
128
+
129
+
130
+ def find_message_arrays(data: Any) -> List[List[Dict[str, Any]]]:
131
+ """
132
+ Collect every chat-message array in a parsed conversation file.
133
+
134
+ Handles a bare message array, {"messages": [...]}, ariadne's
135
+ {"conversations": [{"messages": [...]}, ...]}, and JSONL files whose
136
+ lines are any of the above.
137
+
138
+ Returns:
139
+ List[List[Dict[str, Any]]]: One entry per conversation found.
140
+ """
141
+ if is_message_array(data):
142
+ return [data]
143
+ if isinstance(data, dict):
144
+ if is_message_array(data.get("messages")):
145
+ return [data["messages"]]
146
+ if isinstance(data.get("conversations"), list):
147
+ found = []
148
+ for convo in data["conversations"]:
149
+ found.extend(find_message_arrays(convo))
150
+ return found
151
+ return []
152
+ if isinstance(data, list):
153
+ found = []
154
+ for item in data:
155
+ found.extend(find_message_arrays(item))
156
+ return found
157
+ return []
158
+
159
+
160
+ def messages_to_exchanges(
161
+ messages: List[Dict[str, Any]], target: str
162
+ ) -> Tuple[List[List[str]], str]:
163
+ """
164
+ Convert a chat-message array (role/content, optionally name) into
165
+ [question, answer] exchanges, treating the target's (or assistant's)
166
+ turns as answers.
167
+
168
+ Returns:
169
+ Tuple[List[List[str]], str]: The exchanges and the most frequent
170
+ questioner name found (empty string if none).
171
+ """
172
+
173
+ def is_answer(msg: Dict[str, Any]) -> bool:
174
+ if target and msg.get("name", "").lower() == target.lower():
175
+ return True
176
+ return msg.get("role") == "assistant"
177
+
178
+ exchanges: List[List[str]] = []
179
+ q_names: Dict[str, int] = {}
180
+ pending_q: List[str] = []
181
+ for msg in messages:
182
+ content = str(msg.get("content", "")).strip()
183
+ if not content or msg.get("role") == "system":
184
+ continue
185
+ if is_answer(msg):
186
+ if pending_q:
187
+ exchanges.append(["\n".join(pending_q), content])
188
+ pending_q = []
189
+ elif exchanges:
190
+ exchanges[-1][1] += f"\n{content}"
191
+ else:
192
+ pending_q.append(content)
193
+ q_name = msg.get("name", "")
194
+ if q_name:
195
+ q_names[q_name] = q_names.get(q_name, 0) + 1
196
+
197
+ questioner = max(q_names, key=lambda k: q_names[k]) if q_names else ""
198
+ return exchanges, questioner
199
+
200
+
201
+ def structure_raw_conversation(raw_text: str, target: str) -> Dict[str, Any]:
202
+ """
203
+ Use an LLM to turn an unstructured conversation into transcript JSON.
204
+
205
+ Args:
206
+ raw_text (str): The raw conversation text.
207
+ target (str): Name of the person being emulated.
208
+
209
+ Returns:
210
+ Dict[str, Any]: participants/date/url/exchanges dict.
211
+ """
212
+ client = OpenAI()
213
+ if len(raw_text) > MAX_STRUCTURE_CHARS:
214
+ hx.warn(
215
+ f"source is {len(raw_text)} chars; structuring the first "
216
+ f"{MAX_STRUCTURE_CHARS} -- split long files for full coverage"
217
+ )
218
+ raw_text = raw_text[:MAX_STRUCTURE_CHARS]
219
+ response = client.chat.completions.create(
220
+ model=STRUCTURER_MODEL,
221
+ response_format={"type": "json_object"},
222
+ messages=[
223
+ {"role": "system", "content": STRUCTURER_SYSTEM_PROMPT},
224
+ {
225
+ "role": "user",
226
+ "content": f"The target is: {target}\n\n---\n\n{raw_text}",
227
+ },
228
+ ],
229
+ )
230
+ data = json.loads(response.choices[0].message.content or "{}")
231
+ if not isinstance(data.get("exchanges"), list) or not data["exchanges"]:
232
+ raise ValueError("the LLM could not extract any exchanges")
233
+ data.setdefault("participants", {})
234
+ data["participants"].setdefault("q", "Interviewer")
235
+ data["participants"].setdefault("a", target)
236
+ return data
237
+
238
+
239
+ def import_conversation_file(name: str, path: str, target: str) -> str:
240
+ """
241
+ Import one conversation file (structured or not) as a transcript.
242
+
243
+ Recognises: raft transcript JSON, chat-message arrays (bare list or
244
+ {"messages": [...]} / {"conversations": [...]}), anything else is
245
+ treated as raw text and structured with an LLM.
246
+
247
+ Returns:
248
+ str: The transcript path written.
249
+ """
250
+ with open(path) as f:
251
+ raw = f.read()
252
+
253
+ data = parse_json_or_jsonl(raw) if path.endswith((".json", ".jsonl")) else None
254
+ today = datetime.now(timezone.utc).date().isoformat()
255
+
256
+ if is_transcript(data):
257
+ return write_transcript(
258
+ name,
259
+ data["participants"],
260
+ data.get("date") or today,
261
+ data.get("url") or path,
262
+ data["exchanges"],
263
+ )
264
+
265
+ conversations = find_message_arrays(data)
266
+ if conversations:
267
+ exchanges: List[List[str]] = []
268
+ questioners: Dict[str, int] = {}
269
+ for messages in conversations:
270
+ got, questioner = messages_to_exchanges(messages, target)
271
+ exchanges.extend(got)
272
+ if questioner:
273
+ questioners[questioner] = questioners.get(questioner, 0) + 1
274
+ if not exchanges:
275
+ raise ValueError(f"{path}: no question/answer exchanges found")
276
+ top = max(questioners, key=lambda k: questioners[k]) if questioners else ""
277
+ meta = data if isinstance(data, dict) else {}
278
+ return write_transcript(
279
+ name,
280
+ {"q": top or "Interviewer", "a": target},
281
+ meta.get("date") or today,
282
+ meta.get("url") or path,
283
+ exchanges,
284
+ )
285
+
286
+ hx.step(f"{path}: unstructured, extracting exchanges with {STRUCTURER_MODEL}")
287
+ structured = structure_raw_conversation(raw, target)
288
+ return write_transcript(
289
+ name,
290
+ structured["participants"],
291
+ structured.get("date") or today,
292
+ structured.get("url") or path,
293
+ structured["exchanges"],
294
+ )
295
+
296
+
297
+ def import_text_source_file(name: str, path: str) -> int:
298
+ """
299
+ Append a text source (structured grounding jsonl or raw text) to the
300
+ grounding corpus data/{name}.jsonl.
301
+
302
+ Returns:
303
+ int: Number of documents appended.
304
+ """
305
+ corpus_path = f"data/{name}.jsonl"
306
+ os.makedirs("data", exist_ok=True)
307
+
308
+ with open(path) as f:
309
+ raw = f.read()
310
+
311
+ records: List[Dict[str, Any]] = []
312
+ if path.endswith(".jsonl"):
313
+ try:
314
+ parsed = [json.loads(line) for line in raw.splitlines() if line.strip()]
315
+ if all(isinstance(p, dict) and "content" in p for p in parsed):
316
+ records = parsed
317
+ except json.JSONDecodeError:
318
+ records = []
319
+
320
+ if not records:
321
+ mtime = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc)
322
+ title = re.sub(r"[_-]+", " ", os.path.splitext(os.path.basename(path))[0])
323
+ records = [
324
+ {
325
+ "title": title,
326
+ "link": path,
327
+ "date": mtime.date().isoformat(),
328
+ "content": raw,
329
+ }
330
+ ]
331
+
332
+ with open(corpus_path, "a") as f:
333
+ for record in records:
334
+ record.setdefault("title", "untitled")
335
+ record.setdefault("link", path)
336
+ record.setdefault("date", datetime.now(timezone.utc).date().isoformat())
337
+ f.write(json.dumps(record) + "\n")
338
+ return len(records)