Skip to content

Commit 725b2ad

Browse files
authored
Merge pull request #2 from CppDigest/feature/exclusion-rules
Add optional exclusion rules for sensitive projects/chats
2 parents ea54c62 + 1e1372e commit 725b2ad

15 files changed

Lines changed: 1789 additions & 102 deletions

api/export_api.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
from datetime import datetime
1515
from pathlib import Path
1616

17-
from flask import Blueprint, Response, jsonify, request
17+
from flask import Blueprint, Response, current_app, jsonify, request
1818

1919
from utils.workspace_path import resolve_workspace_path
2020
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
2121
from utils.text_extract import extract_text_from_bubble
2222
from utils.tool_parser import parse_tool_call
23+
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
2324

2425
bp = Blueprint("export_api", __name__)
2526

@@ -70,6 +71,13 @@ def get_export_state():
7071

7172
@bp.route("/api/export", methods=["POST"])
7273
def export_chats():
74+
"""Export chats as a zip archive.
75+
76+
Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
77+
each chat's project name, title, and model. Rules are loaded once at
78+
application startup; an app restart is required to pick up changes to the
79+
exclusion rules file.
80+
"""
7381
try:
7482
body = request.get_json(silent=True) or {}
7583
since = "last" if body.get("since") == "last" else "all"
@@ -94,8 +102,10 @@ def export_chats():
94102
conn.row_factory = sqlite3.Row
95103

96104
# Build workspace mapping
105+
from urllib.parse import unquote as _url_unquote
97106
workspace_entries = []
98107
ws_id_to_slug = {}
108+
ws_id_to_display_name = {} # human-readable, URL-decoded folder name
99109
for name in os.listdir(workspace_path):
100110
full = os.path.join(workspace_path, name)
101111
wj = os.path.join(full, "workspace.json")
@@ -104,11 +114,13 @@ def export_chats():
104114
try:
105115
with open(wj, "r", encoding="utf-8") as f:
106116
wd = json.load(f)
107-
first_folder = wd.get("folder") or (wd.get("folders", [{}])[0] or {}).get("path")
108-
if first_folder:
117+
folders = get_workspace_folder_paths(wd)
118+
first_folder = folders[0] if folders else None
119+
if isinstance(first_folder, str) and first_folder:
109120
fn = first_folder.replace("\\", "/").split("/")[-1]
110121
if fn:
111122
ws_id_to_slug[name] = _slug(fn)
123+
ws_id_to_display_name[name] = _url_unquote(fn)
112124
except Exception:
113125
pass
114126

@@ -155,6 +167,7 @@ def export_chats():
155167

156168
today = datetime.now().strftime("%Y-%m-%d")
157169
exported = []
170+
rules = current_app.config.get("EXCLUSION_RULES") or []
158171

159172
for row in composer_rows:
160173
composer_id = row["key"].split(":")[1]
@@ -170,7 +183,27 @@ def export_chats():
170183

171184
ws_id = composer_id_to_ws.get(composer_id, "global")
172185
ws_slug = "other-chats" if ws_id == "global" else (ws_id_to_slug.get(ws_id) or _slug(ws_id[:12]))
186+
ws_display_name = "Other chats" if ws_id == "global" else (ws_id_to_display_name.get(ws_id) or ws_slug)
173187
title = cd.get("name") or f"Chat {composer_id[:8]}"
188+
model_config = cd.get("modelConfig") or {}
189+
model_name = model_config.get("modelName")
190+
model_names = [model_name] if model_name and model_name != "default" else None
191+
bubble_texts = []
192+
for h in headers:
193+
b = bubble_map.get(h.get("bubbleId"))
194+
if not b:
195+
continue
196+
bt = extract_text_from_bubble(b)
197+
if bt:
198+
bubble_texts.append(bt)
199+
searchable = build_searchable_text(
200+
project_name=ws_display_name,
201+
chat_title=title,
202+
model_names=model_names,
203+
chat_content_snippet="\n\n".join(bubble_texts) if bubble_texts else None,
204+
)
205+
if is_excluded_by_rules(rules, searchable):
206+
continue
174207
title_slug = _slug(title)
175208
ts_ms = updated_at_ms or int(datetime.now().timestamp() * 1000)
176209
ts_str = datetime.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%dT%H-%M-%S")
@@ -264,8 +297,6 @@ def export_chats():
264297
md += f"updated_at: {datetime.fromtimestamp(updated_at_ms / 1000).isoformat() if updated_at_ms else datetime.now().isoformat()}\n"
265298
md += f"workspace: {ws_slug}\n"
266299
md += f"message_count: {len(bubbles)}\n"
267-
model_config = cd.get("modelConfig") or {}
268-
model_name = model_config.get("modelName")
269300
if model_name:
270301
md += f"model: {model_name}\n"
271302
if total_response_ms:

api/search.py

Lines changed: 129 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,66 @@
88
import re
99
import sqlite3
1010
from datetime import datetime
11+
from urllib.parse import unquote as _url_unquote
1112

12-
from flask import Blueprint, jsonify, request
13+
from flask import Blueprint, current_app, jsonify, request
1314

15+
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
1416
from utils.workspace_path import resolve_workspace_path
1517
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
1618
from utils.text_extract import extract_text_from_bubble
1719

1820
bp = Blueprint("search", __name__)
1921

2022

23+
def _json_dump_safe(value) -> str:
24+
"""Best-effort JSON string conversion for exclusion matching."""
25+
try:
26+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
27+
except Exception:
28+
return str(value) if value is not None else ""
29+
30+
31+
def _workspace_display_name_from_folder(folder: str | None, fallback: str | None = None) -> str:
32+
"""Extract a human-readable workspace name from workspace folder path."""
33+
if folder:
34+
raw = str(folder).strip()
35+
cleaned = re.sub(r"^file://", "", raw).replace("\\", "/")
36+
parts = cleaned.split("/")
37+
leaf = parts[-1] if parts else ""
38+
if leaf:
39+
return _url_unquote(leaf)
40+
return fallback or "Other chats"
41+
42+
43+
def _build_exclusion_searchable(
44+
*,
45+
project_name: str | None,
46+
chat_title: str | None,
47+
model_names: list[str] | None = None,
48+
content_parts: list[str] | None = None,
49+
metadata_parts: list[str] | None = None,
50+
) -> str:
51+
"""Build broad searchable text so exclusion rules cover visible output."""
52+
combined = []
53+
if content_parts:
54+
combined.extend(p for p in content_parts if p)
55+
if metadata_parts:
56+
combined.extend(p for p in metadata_parts if p)
57+
return build_searchable_text(
58+
project_name=project_name,
59+
chat_title=chat_title,
60+
model_names=model_names,
61+
chat_content_snippet="\n\n".join(combined) if combined else None,
62+
)
63+
64+
2165
@bp.route("/api/search")
2266
def search():
2367
try:
2468
query = request.args.get("q", "").strip()
2569
search_type = request.args.get("type", "all")
70+
rules = current_app.config.get("EXCLUSION_RULES") or []
2671

2772
if not query:
2873
return jsonify({"error": "No search query provided"}), 400
@@ -58,7 +103,7 @@ def search():
58103
parts = first_folder.replace("\\", "/").split("/")
59104
fn = parts[-1] if parts else None
60105
if fn:
61-
ws_id_to_name[name] = fn
106+
ws_id_to_name[name] = _url_unquote(fn)
62107
except Exception:
63108
pass
64109
except Exception:
@@ -114,41 +159,72 @@ def search():
114159
if not headers:
115160
continue
116161

162+
title = cd.get("name") or ""
163+
ws_id = composer_id_to_ws.get(composer_id, "global")
164+
ws_name = ws_id_to_name.get(ws_id)
165+
project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id)
166+
167+
model_config = cd.get("modelConfig") or {}
168+
model_name = model_config.get("modelName")
169+
model_names = [model_name] if model_name and model_name != "default" else None
170+
171+
bubble_texts = []
172+
bubble_meta = []
173+
for header in headers:
174+
bid = header.get("bubbleId")
175+
bubble_entry = bubble_map.get(bid)
176+
if not bubble_entry:
177+
continue
178+
text = bubble_entry.get("text") or ""
179+
if text:
180+
bubble_texts.append(text)
181+
raw_bubble = bubble_entry.get("raw")
182+
if raw_bubble:
183+
bubble_meta.append(_json_dump_safe(raw_bubble))
184+
185+
exclusion_text = _build_exclusion_searchable(
186+
project_name=project_name,
187+
chat_title=title,
188+
model_names=model_names,
189+
content_parts=bubble_texts,
190+
metadata_parts=[
191+
_json_dump_safe(model_config),
192+
_json_dump_safe(cd.get("conversationSummary")),
193+
_json_dump_safe(cd.get("usage")),
194+
_json_dump_safe(cd.get("requestMetadata")),
195+
_json_dump_safe(cd),
196+
"\n".join(bubble_meta),
197+
],
198+
)
199+
if is_excluded_by_rules(rules, exclusion_text):
200+
continue
201+
117202
# Check if any bubble text matches
118203
has_match = False
119204
matching_text = ""
120-
title = cd.get("name") or ""
121-
122205
# Check title
123206
if title and query_lower in title.lower():
124207
has_match = True
125208
matching_text = title
126209

127210
# Check bubble texts
128211
if not has_match:
129-
for header in headers:
130-
bid = header.get("bubbleId")
131-
bubble_entry = bubble_map.get(bid)
132-
if bubble_entry:
133-
text = bubble_entry["text"]
134-
if text and query_lower in text.lower():
135-
has_match = True
136-
# Extract a snippet around the match
137-
idx = text.lower().find(query_lower)
138-
start = max(0, idx - 80)
139-
end = min(len(text), idx + len(query) + 120)
140-
matching_text = ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
141-
break
212+
for text in bubble_texts:
213+
if text and query_lower in text.lower():
214+
has_match = True
215+
# Extract a snippet around the match
216+
idx = text.lower().find(query_lower)
217+
start = max(0, idx - 80)
218+
end = min(len(text), idx + len(query) + 120)
219+
matching_text = ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
220+
break
142221

143222
if has_match:
144-
ws_id = composer_id_to_ws.get(composer_id, "global")
145-
ws_name = ws_id_to_name.get(ws_id)
146223
if not title:
147224
# Derive title from first bubble
148-
for header in headers:
149-
be = bubble_map.get(header.get("bubbleId"))
150-
if be and be["text"]:
151-
first_lines = [l for l in be["text"].split("\n") if l.strip()]
225+
for text in bubble_texts:
226+
if text:
227+
first_lines = [l for l in text.split("\n") if l.strip()]
152228
if first_lines:
153229
title = first_lines[0][:100]
154230
break
@@ -191,6 +267,7 @@ def search():
191267
workspace_folder = wd.get("folder")
192268
except Exception:
193269
pass
270+
workspace_name = _workspace_display_name_from_folder(workspace_folder, fallback=name)
194271

195272
try:
196273
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
@@ -203,10 +280,38 @@ def search():
203280
if chat_row and chat_row[0]:
204281
data = json.loads(chat_row[0])
205282
for tab in (data.get("tabs") or []):
283+
ct = tab.get("chatTitle") or ""
284+
tab_model_names = None
285+
tab_meta = tab.get("metadata")
286+
if isinstance(tab_meta, dict):
287+
models_used = tab_meta.get("modelsUsed")
288+
if isinstance(models_used, list):
289+
tab_model_names = [str(m) for m in models_used if m]
290+
elif tab_meta.get("model"):
291+
tab_model_names = [str(tab_meta.get("model"))]
292+
293+
tab_bubble_texts = []
294+
for bubble in (tab.get("bubbles") or []):
295+
text = bubble.get("text") or ""
296+
if text:
297+
tab_bubble_texts.append(text)
298+
299+
exclusion_text = _build_exclusion_searchable(
300+
project_name=workspace_name,
301+
chat_title=ct,
302+
model_names=tab_model_names,
303+
content_parts=tab_bubble_texts,
304+
metadata_parts=[
305+
_json_dump_safe(tab),
306+
_json_dump_safe(workspace_folder),
307+
],
308+
)
309+
if is_excluded_by_rules(rules, exclusion_text):
310+
continue
311+
206312
has_match = False
207313
matching_text = ""
208314

209-
ct = tab.get("chatTitle") or ""
210315
if ct.lower().find(query_lower) != -1:
211316
has_match = True
212317
matching_text = ct

0 commit comments

Comments
 (0)