From ac181022e567455dca479bd2cfe1cd7a3f896e72 Mon Sep 17 00:00:00 2001 From: Lasse Server Date: Mon, 3 Aug 2026 08:29:10 +0200 Subject: [PATCH] Remove the last ArangoDB remnants; exclude .claude/ wholesale This project moved to Postgres some time ago, but ~380 lines of SQL-to-AQL translation and AQL syntax validation were still sitting in utils.py, reachable from nothing. utils.py is now just TextChunker, which is the only part anything imports. Also drops a stray prompt draft that had been left in backend/services/, and the _normalize_arango_search_args name, which never touched Arango. Worth knowing separately: the Arango server is still running and still authenticating, so the riksdagsgruppen account is a live door into a system nothing here uses any more. Deleting that account is simpler than rotating its password. The env_manager account is a different one and other projects still depend on it. .gitignore now excludes .claude/ entirely rather than just settings.local.json. settings.json can carry credentials inside allowlisted command strings, which is exactly how a live database password ended up committed to the predecessor. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +- _postgres/_postgres.py | 2 +- backend/services/llm_tools.py | 10 +- backend/services/search.py | 2 - backend/services/text.md | 65 ------ postgres_client.py | 2 +- scripts/debates.py | 1 - scripts/make_embeddings.py | 1 - utils.py | 379 ---------------------------------- 9 files changed, 11 insertions(+), 456 deletions(-) delete mode 100644 backend/services/text.md diff --git a/.gitignore b/.gitignore index 63bb3b1..992700d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,10 @@ parliament.local.yaml *.p12 id_rsa* credentials*.json -.claude/settings.local.json +# Claude Code workspace config. Excluded wholesale: settings.json can carry +# credentials inside allowlisted commands, which is how a live database password +# ended up committed to the predecessor repository. +.claude/ # Archives. A WireGuard backup was committed to the predecessor repo this way. *.tgz diff --git a/_postgres/_postgres.py b/_postgres/_postgres.py index 5672383..ea31ff8 100644 --- a/_postgres/_postgres.py +++ b/_postgres/_postgres.py @@ -1,7 +1,7 @@ """ PostgreSQL client for the Riksdagen project. -Provides a simple interface mirroring _arango/_arango.py, using psycopg2 +A small wrapper over psycopg2 with a thread-safe connection pool. Includes pgvector support for embeddings. Environment variables: diff --git a/backend/services/llm_tools.py b/backend/services/llm_tools.py index dfab872..d59a5e7 100644 --- a/backend/services/llm_tools.py +++ b/backend/services/llm_tools.py @@ -173,7 +173,7 @@ _fast_llm_var: ContextVar[Optional[Any]] = ContextVar( # ───────────────────────────────────────────────────────────────────────────── -# database_query (direct SQL — no more SQL→AQL translation) +# database_query # ───────────────────────────────────────────────────────────────────────────── @register_tool() @@ -1115,10 +1115,10 @@ def read_documents_for(question: str, _ids: list[str]) -> str: # ───────────────────────────────────────────────────────────────────────────── -# _normalize_arango_search_args (unchanged helper) +# _normalize_search_args (unchanged helper) # ───────────────────────────────────────────────────────────────────────────── -def _normalize_arango_search_args( +def _normalize_search_args( query: str, parties: Optional[Union[str, List[str]]] = None, people: Optional[Union[str, List[str]]] = None, @@ -1225,7 +1225,7 @@ def search_speeches( if bad: return f"ERROR: person_ids must be numeric strings (e.g. '0448485371626'). Invalid values: {bad}. Use the `people` parameter to search by name, or only pass person_ids you have seen in previous results." - args = _normalize_arango_search_args( + args = _normalize_search_args( query=query, parties=parties, people=people, @@ -1406,7 +1406,7 @@ def search_documents( if bad: return f"ERROR: person_ids must be numeric strings (e.g. '0448485371626'). Invalid values: {bad}. Use the `people` parameter to search by name, or only pass person_ids you have seen in previous results." - args = _normalize_arango_search_args( + args = _normalize_search_args( query=query, parties=parties, people=people, diff --git a/backend/services/search.py b/backend/services/search.py index 3a626b5..c9f721c 100644 --- a/backend/services/search.py +++ b/backend/services/search.py @@ -1,8 +1,6 @@ # SearchService using PostgreSQL full-text search (tsvector/tsquery) with Swedish dictionary. # -# Replaces the ArangoSearch-based implementation. # - BM25 → ts_rank_cd() (similar ranking, different formula) -# - ArangoSearch view → GIN index on search_vector (tsvector column) # - OFFSET_INFO highlighting → ts_headline() # - PHRASE() → phraseto_tsquery() # - TOKENS() → plainto_tsquery() diff --git a/backend/services/text.md b/backend/services/text.md deleted file mode 100644 index 0336414..0000000 --- a/backend/services/text.md +++ /dev/null @@ -1,65 +0,0 @@ -```` -You help users find information in speeches from the Swedish Riksdag. You have several tools available to search the speeches database; use these tools whenever you need data not present in earlier messages. - -Important operational rules: -- Always read each tool's description and arguments carefully before calling it; follow examples. -- When presenting results, cite sources by mentioning the talk titles and dates when available. -- You may call multiple tools in one conversation; if one tool doesn't return what you need, call another. -- Summarize and analyze findings continuously so you know what you have and what you still need. -- Do not output JSON as your final answer — reply in natural text when speaking to users. - -Decision / tool-selection map: -- Use `vector_search_talks(query, limit)` for semantic / concept matches (conceptual similarity, thematic clustering). -- Use `search_speeches(query, parties, people, from_year, to_year, limit)` for ranked full-text searches (language-aware, boolean/phrase search, highlighted snippets). -- Use `aql_query(query)` for exact/structured queries, joins, and aggregations (you must write AQL; see the tool's docstring for templates). -- Use `fetch_document(_id)` to retrieve an entire document when you need the full text. - -AQL guidance (summary): -- Always prefer the ArangoSearch view `talks_search` for text searches (use `SEARCH` and analyzers like `text_sv`). -- Use collection-level queries on `talks` (and `people`) for joins, `COLLECT`/aggregations, and exact structured work. -- Use bind parameters (e.g., @term) — never interpolate raw user strings into queries. -- If you produce SQL-like syntax, you will receive a syntax-error response. In that case: rewrite the request using the provided AQL templates. - -When returning queries or calling the AQL tool, be explicit which template you used (e.g. "I used the 'count documents mentioning a term' template"). If the user intent is ambiguous (count documents vs. count total occurrences), pick the interpretation that is most useful and state which you used. - -Respond concisely, include citations to talks (title + date) when giving evidentiary claims, and prefer the AQL templates in the tool docstring for exact queries. -``` - - -```` -Du hjälper användare att hitta information i tal från Sveriges Riksdag. -Du har ett antal verktyg tillgängliga för att söka i taldatabasen och hitta relevanta källor. Använd dessa verktyg när du behöver hitta information som inte finns i tidigare meddelanden i konversationen. -VIKTIGT! Se till att noga läsa beskrivningen av verktygen och deras argument så att du använder dem korrekt. Det finns ofta exempel, titta på dem! -När du återger svaret: referera till källor genom att nämna talens rubriker och datum, om sådana finns, så se till att du har dessa källor i åtanke. -Du kan använda flera verktyg i samma konversation, så får du inte den information du behöver så använd ett annat verktyg. -Se till att hela tiden sammanfatta och analysera den värdefulla informationen du hittar så att du förstår vad du vet och vilka verktyg du eventuellt behöver använda för att hitta rätt information. -Använd aldrig json-format för att svara, utan skriv alltid i löpande text! - -OBS! Ett vanligt fel när man använder AQL är att råka använda SQL-syntax. Om det händer får du ett meddelande om syntaxfel och eventuellt vad som är fel. I så fall, skriv om frågan med korrekt AQL-syntax och försök igen. Överanalysera inte! - -# Guide on tool selection - -Use this tiny decision map when choosing which tool to call. - -## Quick rules -- **Semantic / “meaning” search** → `vector_search_talks(query, limit)` - Use when the user asks for conceptually similar speeches, thematic matches, or you want few high-relevance snippets to summarize or paraphrase. - -- **Full-text + filters (language-aware, ranked)** → `search_speeches(query, parties, people, from_year, to_year, limit)` - Use for Google-like queries with boolean operators, phrase search, party/speaker/year filters and highlighted snippets. - -- **Exact/structured queries, aggregates, joins, date ranges** → `aql_query(query)` - Use when you need to do exact matches, aggregations (counts, group by), or complex logic not possible with the above tools. You must write the AQL query yourself. Make sure to understand the schema! - -- **Fetch full document** → `fetch_document(_id)` - Use when you need the entire text of a specific talk, person or other document. You need the unique _id. - -## Handy one-line decision -- Need **meaning** → `vector_search_talks` -- Need **ranked full-text+filters** → `search_speeches` -- Need **exact/aggregated/make statistics** → `aql_query` -- Need **full document** → `fetch_document` -``` - - - diff --git a/postgres_client.py b/postgres_client.py index b63dae1..31f5cf9 100644 --- a/postgres_client.py +++ b/postgres_client.py @@ -1,5 +1,5 @@ """ -Root-level PostgreSQL client singleton, mirroring arango_client.py. +Root-level PostgreSQL client singleton. Import this in scripts and services: from postgres_client import pg diff --git a/scripts/debates.py b/scripts/debates.py index c371469..737d8c7 100644 --- a/scripts/debates.py +++ b/scripts/debates.py @@ -1,7 +1,6 @@ """ Hanterar debatt-ID:n och sammanfattningar av debatter i PostgreSQL. -Ersätter den ArangoDB-baserade versionen. Funktioner: assign_debate_ids(docs, date) → lägger till 'debate' fält i dokumentlistan diff --git a/scripts/make_embeddings.py b/scripts/make_embeddings.py index 731bad8..050206d 100644 --- a/scripts/make_embeddings.py +++ b/scripts/make_embeddings.py @@ -1,7 +1,6 @@ """ Chunks and embeds all speeches that are not yet represented in the 'speech_chunks' table. -Replaces scripts/make_arango_embeddings.py. Pipeline: 1. Find all speeches with no chunk rows in PostgreSQL via LEFT JOIN. diff --git a/utils.py b/utils.py index edb4a9d..a371f2a 100644 --- a/utils.py +++ b/utils.py @@ -418,382 +418,3 @@ class TextChunker: print(f"Chunk sizes: {[len(c) for c in speech_chunks]}") return speech_chunks - - -def detect_sql_syntax(query: str) -> dict: - """ - Detects if a query contains SQL syntax instead of AQL. - - Args: - query: The query string to check - - Returns: - dict with keys: - - is_sql: bool, True if SQL patterns detected - - issues: list of detected SQL patterns - - suggestion: str, helpful message for the LLM - """ - query_upper = query.upper() - issues = [] - - # Common SQL patterns that don't exist in AQL - sql_patterns = [ - (r"\bINNER\s+JOIN\b", "Found 'INNER JOIN'"), - (r"\bIS\s+NULL\b", "Found 'IS NULL' - SQL null test"), - (r"\bHAVING\b", "Found 'HAVING' - SQL post-aggregation filter"), - (r"\bHAVING\b", "Found 'HAVING' - use FILTER after COLLECT instead"), - (r"\bINSTR\s*\(", "Found 'INSTR' - Oracle string position function"), - (r"\bORDER\s+BY\b", "Found 'ORDER BY' - use 'SORT' instead"), - (r"\bPOSITION\s*\(", "Found 'POSITION' - SQL POSITION function"), - ( - r"\bCASE\b[\s\S]{0,200}\bWHEN\b", - "Found 'CASE ... WHEN' - SQL conditional expression", - ), - (r"\bINNER\s+JOIN\b", "Found 'INNER JOIN' - use nested FOR loops instead"), - (r"\bSTRING_AGG\s*\(", "Found 'STRING_AGG(' - Postgres aggregate"), - (r"\bRIGHT\s+JOIN\b", "Found 'RIGHT JOIN'"), - ( - r"\bSUBSTRING\s*\(", - "Found 'SUBSTRING' - SQL substring function (AQL uses SUBSTRING() but with diff. semantics; beware false positives)", - ), - (r"\bOVER\s*\(", "Found 'OVER(' - SQL window clause"), - (r"\bWHERE\b", "Found 'WHERE' - SQL WHERE (AQL uses FILTER)"), - (r"\bREGEXP_LIKE\s*\(", "Found 'REGEXP_LIKE' - SQL regex function, not in AQL"), - (r"\bPATINDEX\s*\(", "Found 'PATINDEX' - T-SQL pattern search"), - ( - r"\bJOIN\s+\w+\s+ON\b", - "Found 'JOIN ... ON' - use nested FOR loops with FILTER instead", - ), - (r"\bSTRPOS\s*\(", "Found 'STRPOS' - Postgres string position function"), - (r"\bWHERE\b", "Found 'WHERE' - use 'FILTER' instead"), - (r"\bPARTITION\s+BY\b", "Found 'PARTITION BY' - window function partitioning"), - (r"\bCAST\s*\([^)]*\s+AS\s+\w+\)", "Found 'CAST(... AS type)' - SQL cast"), - (r"\bLIKE\b", "Found 'LIKE' - SQL pattern match (AQL uses LIKE(field, pattern) as a function, not an operator)"), - (r"\bILIKE\b", "Found 'ILIKE' - Postgres case-insensitive LIKE"), - (r"\bMATCHES\b", "Found 'MATCHES' - not an AQL operator; use FILTER with == or LIKE() function, or SEARCH + TOKENS"), - (r"CONTAINS\s+['\"%]", "Found 'CONTAINS value' as operator - not valid AQL; use LIKE(field, pattern) or SEARCH + TOKENS"), - (r"\bGROUP\s+BY\b", "Found 'GROUP BY' - AQL equivalent: COLLECT"), - ( - r"\bMIN\s*\(\s*\w+\.\w+\s*\)", - "Found 'MIN(table.column)' - use 'RETURN MIN(doc.field)' or aggregate in COLLECT instead", - ), - (r"\bCOUNT\s*\(", "Found 'COUNT(' - SQL aggregate"), - ( - r"\bREGEXP_REPLACE\s*\(", - "Found 'REGEXP_REPLACE' - SQL regex function, not in AQL", - ), - (r"\bMIN\s*\(", "Found 'MIN(' - SQL aggregate"), - ( - r"\bOFFSET\s+\d+", - "Found 'OFFSET' alone - in AQL use 'LIMIT offset, count' format", - ), - ( - r"\bAVG\s*\(\s*\w+\.\w+\s*\)", - "Found 'AVG(table.column)' - use 'RETURN AVG(doc.field)' or aggregate in COLLECT instead", - ), - ( - r"\bAS\s+\w+\s+FROM\b", - "Found table alias with 'AS' - AQL doesn't use AS for collections", - ), - (r"\bUNION\b", "Found 'UNION' - SQL set union"), - ( - r"\bWITH\s+\w+\s+AS\s*\(", - "Found CTE 'WITH name AS (' - common table expression", - ), - (r"\bGROUP_CONCAT\s*\(", "Found 'GROUP_CONCAT(' - MySQL aggregate"), - (r"\bMAX\s*\(", "Found 'MAX(' - SQL aggregate"), - (r"\bTOP\s+\d+\b", "Found 'TOP N' - SQL Server style (pagination)"), - ( - r"\bREGEXP_INSTR\s*\(", - "Found 'REGEXP_INSTR' - SQL regex function, not in AQL", - ), - (r"\bROW_NUMBER\s*\(", "Found 'ROW_NUMBER(' - SQL window function"), - (r"\bLEFT\s+JOIN\b", "Found 'LEFT JOIN'"), - (r"\bJOIN\b", "Found 'JOIN' - use nested FOR loops in AQL"), - ( - r"\bLENGTH\s*\(", - "Found 'LENGTH' - SQL string length (AQL uses LENGTH() but semantics differ: counts array elements too)", - ), - (r"\bSELECT\s+", "Found 'SELECT' - use 'FOR ... IN ... RETURN' instead"), - ( - r"\bCOUNT\s*\(\s*\*\s*\)", - "Found 'COUNT(*)' - use 'COLLECT WITH COUNT INTO var' instead", - ), - ( - r"\bSUM\s*\(\s*\w+\.\w+\s*\)", - "Found 'SUM(table.column)' - use 'RETURN SUM(doc.field)' or aggregate in COLLECT instead", - ), - (r"\bSELECT\s+", "Found 'SELECT' - SQL-style SELECT"), - (r"\bOFFSET\b", "Found 'OFFSET' - SQL-style pagination (watch variants)"), - ( - r"\bSELECT\b[\s\S]{0,400}\bFROM\b", - "Found 'SELECT ... FROM' - SQL-style query (use 'FOR ... IN ... RETURN')", - ), - (r"\bDISTINCT\b", "Found 'DISTINCT' - SQL DISTINCT (AQL uses COLLECT/UNIQUE)"), - ( - r"\bEXISTS\s*\(\s*SELECT\b", - "Found 'EXISTS (SELECT ...)' - SQL subquery existence check", - ), - (r"\(\s*SELECT\b", "Found '(SELECT ...)' - SQL subquery (nested select)"), - (r"\bON\s+", "Found 'ON' (JOIN condition) - SQL join condition indicator"), - (r"\bSUM\s*\(", "Found 'SUM(' - SQL aggregate"), - (r"\bGROUP\s+BY\b", "Found 'GROUP BY' - use 'COLLECT' instead"), - (r"\bAVG\s*\(", "Found 'AVG(' - SQL aggregate"), - (r"\bRIGHT\s+JOIN\b", "Found 'RIGHT JOIN' - use nested FOR loops instead"), - ( - r"\bFROM\s+\w+\s+WHERE\b", - "Found 'FROM ... WHERE' - use 'FOR ... IN ... FILTER' instead", - ), - (r"\bLEFT\s+JOIN\b", "Found 'LEFT JOIN' - use nested FOR loops instead"), - ( - r"\bMAX\s*\(\s*\w+\.\w+\s*\)", - "Found 'MAX(table.column)' - use 'RETURN MAX(doc.field)' or aggregate in COLLECT instead", - ), - (r"\bCONVERT\s*\([^)]*\)", "Found 'CONVERT(...)' - SQL convert/cast"), - (r"\bBETWEEN\b", "Found 'BETWEEN' - SQL range operator"), - ( - r"\bREGEXP_SUBSTR\s*\(", - "Found 'REGEXP_SUBSTR' - SQL regex function, not in AQL", - ), - (r"\bCHARINDEX\s*\(", "Found 'CHARINDEX' - T-SQL string search"), - ( - r"\bFROM\s+\w+\s+WHERE\b", - "Found 'FROM ... WHERE' - SQL-style; use 'FOR ... IN ... FILTER' in AQL", - ), - ( - r"\bREGEXP_COUNT\s*\(", - "Found 'REGEXP_COUNT' - SQL regex function, not in AQL", - ), - (r"\bORDER\s+BY\b", "Found 'ORDER BY' - AQL uses SORT"), - (r"\bUNION\s+ALL\b", "Found 'UNION ALL' - SQL set union"), - (r"\bIS\s+NOT\s+NULL\b", "Found 'IS NOT NULL' - SQL null test"), - ] - - for pattern, message in sql_patterns: - if re.search(pattern, query_upper): - issues.append(message) - - # Special case: SELECT without FROM (common typo) - if re.search(r"\bSELECT\b", query_upper) and not re.search( - r"\bFOR\s+\w+\s+IN\b", query_upper - ): - if "Found 'SELECT'" not in [i for i in issues]: - issues.append( - "Query starts with SELECT but has no FOR loop - this is SQL, not AQL" - ) - - is_sql = len(issues) > 0 - - suggestion = "" - if is_sql: - suggestion = ( - "ERROR: This query uses SQL syntax, not AQL! " - "AQL (ArangoDB Query Language) syntax:\n" - "- Start with: FOR doc IN collection\n" - "- Filter with: FILTER doc.field == value\n" - "- End with: RETURN doc (or specific fields)\n" - "- For joins: use nested FOR loops\n" - "- For grouping: use COLLECT\n\n" - f"Detected issues:\n" + "\n".join(f"- {issue}" for issue in issues) - ) - - return {"is_sql": is_sql, "issues": issues, "suggestion": suggestion} - - - -import re -from typing import List, Tuple - -def _norm_whitespace(s: str) -> str: - return re.sub(r'\s+', ' ', s).strip() - -def _extract_clause(sql: str, name: str, terminators: List[str]) -> Tuple[str, str]: - """Extract clause `name` (like 'where') returning (content, remainder)""" - pattern = rf'(?i)\b{name}\b\s*(.+)' - m = re.search(pattern, sql) - if not m: - return '', sql - rest = m.group(1) - # cut at first terminator token - min_pos = len(rest) - for t in terminators: - t_re = re.search(rf'(?i)\b{t}\b', rest) - if t_re: - min_pos = min(min_pos, t_re.start()) - return rest[:min_pos].strip(), rest[min_pos:].strip() - -def sql_to_aql(sql: str) -> str: - s = _norm_whitespace(sql).rstrip(';') - s_low = s.lower() - - # SELECT clause - m = re.search(r'(?i)\bselect\b\s+(.+?)\s+\bfrom\b\s', s) - if not m: - raise ValueError("Cannot parse SELECT clause") - select_part = m.group(1).strip() - - # FROM clause (capture table and optional alias) - m = re.search(r'(?i)\bfrom\b\s+([^\s,]+)(?:\s+([a-zA-Z_][\w]*))?', s) - if not m: - raise ValueError("Cannot parse FROM clause") - from_table = m.group(1) - from_alias = m.group(2) if m.group(2) else from_table - - # Find joins (simple INNER JOIN / JOIN) - joins = [] - for jm in re.finditer(r'(?i)\bjoin\b\s+([^\s]+)(?:\s+([a-zA-Z_][\w]*))?\s+\bon\b\s+([^ ]+)', s): - j_table, j_alias, j_on = jm.group(1), (jm.group(2) or jm.group(1)), jm.group(3) - joins.append((j_table, j_alias, j_on)) - - # WHERE - where_part, _ = _extract_clause(s, 'where', ['group by', 'order by', 'limit']) - - # GROUP BY - group_by = '' - m = re.search(r'(?i)\bgroup\s+by\b\s+(.+?)(?:\s+\border\s+by\b|\s+\blimit\b|$)', s) - if m: - group_by = m.group(1).strip() - - # ORDER BY - order_by = '' - m = re.search(r'(?i)\border\s+by\b\s+(.+?)(?:\s+\blimit\b|$)', s) - if m: - order_by = m.group(1).strip() - - # LIMIT / OFFSET - offset = None - limit = None - m = re.search(r'(?i)\blimit\b\s+(\d+)\s*,\s*(\d+)', s) - if m: - offset = int(m.group(1)); limit = int(m.group(2)) - else: - m = re.search(r'(?i)\blimit\b\s+(\d+)', s) - if m: - limit = int(m.group(1)) - m = re.search(r'(?i)\boffset\b\s+(\d+)', s) - if m: - offset = int(m.group(1)) - - # Heuristic: if WHERE contains text LIKE '%term%' or LIKE '%term%' or text ILIKE, map to talks_search + SEARCH TOKENS - use_view_search = False - search_term = None - like_m = re.search(r"(?i)(text)\s+like\s+'%([^%']+)%'", s) - if like_m: - use_view_search = True - search_term = like_m.group(2) - else: - # also check generic LIKE on any column -- if column looks like text, map to view - like_m = re.search(r"(?i)([a-zA-Z0-9_\.]+)\s+like\s+'%([^%']+)%'", s) - if like_m and 'text' in like_m.group(1).lower(): - use_view_search = True - search_term = like_m.group(2) - - # Start building AQL - aql_lines = [] - if use_view_search: - aql_lines.append(f"FOR {from_alias} IN {from_table}_search".replace('_search_search','_search')) # if speeches -> talks_search - else: - aql_lines.append(f"FOR {from_alias} IN {from_table}") - - # add join FOR loops - for j_table, j_alias, j_on in joins: - aql_lines.append(f" FOR {j_alias} IN {j_table}") - - # Convert ON conditions and WHERE into FILTERs - filters = [] - # Add join ON conditions as FILTERs - for _, j_alias, j_on in joins: - # j_on example: p._key = t.person_id - cond = j_on.replace('=', '==') - filters.append(cond.strip()) - - if where_part: - # Basic transformations: = stays ==, <> => !=, AND/OR uppercase, remove table aliases if needed - cond = where_part - cond = re.sub(r'(?i)\s+and\s+', ' AND ', cond) - cond = re.sub(r'(?i)\s+or\s+', ' OR ', cond) - cond = cond.replace('<>', '!=') - cond = cond.replace('=', '==', 1) if ('=' in cond and '==' not in cond) else cond - # don't blindly replace all = -> ==; do cautious: replace operators like ' = ' with ' == ' - cond = re.sub(r'\s=\s', ' == ', cond) - # if LIKE already handled above, skip adding raw LIKE filter - cond = re.sub(r"(?i)\s+like\s+'%[^']+%'", '', cond) - filters.append(cond.strip()) - - for f in filters: - if f: - aql_lines.append(f" FILTER {f}") - - # If use_view_search, add SEARCH line - if use_view_search and search_term: - aql_lines.append(f" SEARCH ANALYZER({from_alias}.text IN TOKENS(\"{search_term}\", \"text_sv\"), \"text_sv\")") - - # SORT / ORDER BY conversion - if order_by: - # simple conversion: replace table.column with same - order_expr = order_by.replace(' desc', ' DESC').replace(' asc', ' ASC') - aql_lines.append(f" SORT {order_expr}") - - # GROUP BY -> COLLECT (simple support for COUNT(*) and grouping by single key) - if group_by: - group_cols = [c.strip() for c in group_by.split(',')] - if len(group_cols) == 1 and re.search(r'(?i)count\(\s*\*\s*\)', select_part): - key = group_cols[0] - # map table.column -> alias.column if no alias - aql_lines.append(f" COLLECT key = {key} WITH COUNT INTO cnt") - aql_lines.append(" SORT cnt DESC") - aql_lines.append(" RETURN { key, count: cnt }") - return "\n".join(aql_lines) - - # LIMIT/OFFSET - if limit is not None: - if offset is None: - aql_lines.append(f" LIMIT {limit}") - else: - aql_lines.append(f" LIMIT {offset}, {limit}") - - # Build the RETURN clause - # if select_part is COUNT(*) or COUNT(1) - if re.search(r'(?i)^count\s*\(\s*\*\s*\)\s*$', select_part.strip()): - aql_lines.append(" COLLECT WITH COUNT INTO c") - aql_lines.append(" RETURN c") - else: - # map columns: simply return them as-is (user may need to adapt aliases) - # Build a nice returned object if multiple columns - cols = [c.strip() for c in select_part.split(',')] - if len(cols) == 1: - col = cols[0] - aql_lines.append(f" RETURN {col}") - else: - ret_items = [] - for c in cols: - # try to make a key: if "t._id" -> _id, if "p.birth_year" -> p_fodd_ar - key = re.sub(r'[^a-zA-Z0-9_]', '_', c) - ret_items.append(f'"{key}": {c}') - ret_map = "{ " + ", ".join(ret_items) + " }" - aql_lines.append(f" RETURN {ret_map}") - - return "\n".join(aql_lines) - - -# ---- small CLI for quick tests ---- -if __name__ == "__main__": - examples = [ - "SELECT COUNT(*) FROM speeches WHERE text LIKE '%korallrev%';", - "SELECT t._id, p.birth_year FROM speeches t JOIN people p ON p._key = t.person_id WHERE t.year = 2016;", - "SELECT party, COUNT(*) FROM speeches WHERE source_datetime >= '2016-01-01' AND source_datetime <= '2016-12-31' GROUP BY party ORDER BY COUNT(*) DESC;" - ] - for sql in examples: - print("SQL:", sql) - try: - print("AQL:\n", sql_to_aql(sql)) - except Exception as e: - print("Error:", e) - print("-" * 60) - - - - -# Example usage: -if __name__ == "__main__": - pass \ No newline at end of file