Ethosoft-Math 0.1.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.
@@ -0,0 +1,1418 @@
1
+ """Cozum metinlerindeki hesaplari MathTool cagrilarina cevirir (v2).
2
+
3
+ Ilke: sadece DOGRULANABILEN hesaplar donusturulur; dogrulanamayan her sey oldugu gibi kalir.
4
+ Kapsam: sayisal eslitlikler, \\boxed cevaplar, yaklasik (≈) degerler, kelimeyle islemler,
5
+ birimler, yuzde, sembolik ozdeslik (expand/factor/simplify), denklem cozme (solve),
6
+ fonksiyon tanimlama (define) ve $...$ icinde tek basina duran sayisal ifadeler.
7
+ """
8
+ import bisect
9
+ import json
10
+ import re
11
+ from decimal import Decimal
12
+ from fractions import Fraction
13
+
14
+ import sympy
15
+
16
+ from .protocol import (CALL_OPEN, CALL_CLOSE, RES_OPEN, RES_CLOSE, ToolError, fmt_frac,
17
+ to_text, run, execute, parse_call, _round_half_up, _sym)
18
+
19
+ # ------------------------------------------------------------------ tokenizer
20
+ _TOK = re.compile(r"""
21
+ (?P<NUM>(?<![\d.])(?:\d{1,3}(?:(?:,\\!|\{,\}|,)\d{3})+(?!\d)|\d+)(?:\.\d+)?|(?<![\d.])\.\d+)
22
+ |(?P<CMD>\\[A-Za-z]+)
23
+ |(?P<ESC>\\[%$,;!:\s_])
24
+ |(?P<WORD>[A-Za-z]+)
25
+ |(?P<SP>\s+)
26
+ |(?P<CH>.)
27
+ """, re.X | re.S)
28
+
29
+ _OPMAP = {"×": "*", "·": "*", "⋅": "*", "∗": "*", "÷": "/", "−": "-", "–": "-", "—": "-"}
30
+ _CMD_OP = {"\\times": "*", "\\cdot": "*", "\\div": "/", "\\ast": "*"}
31
+ _FUNC_WORDS = {"sqrt", "sin", "cos", "tan", "log", "ln", "exp", "abs",
32
+ "asin", "acos", "atan", "gcd", "lcm"}
33
+ _CURRENCY = {"Rs", "INR", "USD", "EUR", "Re", "Rupees", "Dollars"}
34
+ _BINOM = ("\\binom", "\\dbinom", "\\tbinom")
35
+ _CMD_FUNCS = {"\\sin": "sin", "\\cos": "cos", "\\tan": "tan", "\\cot": "cot", "\\sec": "sec",
36
+ "\\csc": "csc", "\\log": "log", "\\ln": "ln", "\\exp": "exp",
37
+ "\\arcsin": "asin", "\\arccos": "acos", "\\arctan": "atan"}
38
+ _CMD_ALLOWED = set(_CMD_OP) | set(_CMD_FUNCS) | {"\\frac", "\\dfrac", "\\tfrac", "\\sqrt", "\\pi",
39
+ "\\circ", "\\boxed", "\\binom", "\\dbinom",
40
+ "\\tbinom", "\\gcd", "\\lcm", "\\sum", "\\prod"}
41
+ _STOP = set("""of and is are was were be been to the an by in on at for with so then thus that which
42
+ if as or we it has have had total each every per from into than this these those there here
43
+ not no yes can will would should could must may might do does did its their his her our your
44
+ what when where who how why also only just more less most least about over under between
45
+ after before again once both all any some such other same new old first second third""".split())
46
+ _NL_SYMS = set("xyznkpqwtr") | (set("ABCDEFGHJKLMNOPQRSTUVWXYZ"))
47
+ _UNIT_TEXT = {"\\text", "\\mbox", "\\mathrm", "\\textbf", "\\mathbf"}
48
+
49
+
50
+ def tokenize(s):
51
+ out = []
52
+ for m in _TOK.finditer(s):
53
+ k, v = m.lastgroup, m.group()
54
+ st, en = m.start(), m.end()
55
+ if k == "NUM":
56
+ out.append(("NUM", v, st, en))
57
+ elif k == "CMD":
58
+ if v in _CMD_OP:
59
+ out.append(("OP", _CMD_OP[v], st, en))
60
+ elif v in ("\\left", "\\right", "\\displaystyle", "\\quad", "\\qquad"):
61
+ out.append(("SP", " ", st, en))
62
+ elif v in ("\\cdots", "\\ldots", "\\dots", "\\cdot\\cdot\\cdot"):
63
+ out.append(("OTHER", "⋯", st, en))
64
+ elif v == "\\approx":
65
+ out.append(("OTHER", "≈", st, en))
66
+ elif v in ("\\le", "\\leq", "\\leqslant"):
67
+ out.append(("OTHER", "≤", st, en))
68
+ elif v in ("\\ge", "\\geq", "\\geqslant"):
69
+ out.append(("OTHER", "≥", st, en))
70
+ elif v == "\\lt":
71
+ out.append(("OTHER", "<", st, en))
72
+ elif v == "\\gt":
73
+ out.append(("OTHER", ">", st, en))
74
+ else:
75
+ out.append(("CMD", v, st, en))
76
+ elif k == "ESC":
77
+ c = v[1]
78
+ if c == "%":
79
+ out.append(("OP", "%", st, en))
80
+ elif c == "$":
81
+ out.append(("DOLLAR", "$", st, en))
82
+ elif c == "_":
83
+ out.append(("OTHER", "_", st, en))
84
+ else:
85
+ out.append(("SP", " ", st, en))
86
+ elif k == "WORD":
87
+ out.append(("ID", v, st, en))
88
+ elif k == "SP":
89
+ out.append(("SP", "\n" if "\n" in v else " ", st, en))
90
+ else:
91
+ c = _OPMAP.get(v, v)
92
+ if c == "$":
93
+ out.append(("DOLLAR", c, st, en))
94
+ elif c == "√":
95
+ out.append(("SQRT", c, st, en))
96
+ elif c == "°":
97
+ out.append(("DEG", c, st, en))
98
+ elif c in "²³":
99
+ out.append(("UNIT", c, st, en))
100
+ elif c in "+-*/^()!{}[]%":
101
+ out.append(("OP", c, st, en))
102
+ else:
103
+ out.append(("OTHER", c, st, en))
104
+ merged, q = [], 0
105
+ while q < len(out):
106
+ a = out[q]
107
+ if a[0] == "OTHER" and a[1] in "<>" and q + 1 < len(out) and out[q + 1][:2] == ("OTHER", "=") \
108
+ and out[q + 1][2] == a[3]:
109
+ merged.append(("OTHER", "≤" if a[1] == "<" else "≥", a[2], out[q + 1][3]))
110
+ q += 2
111
+ continue
112
+ merged.append(a)
113
+ q += 1
114
+ out = merged
115
+ # \text{...} -> UNIT (\mathrm{gcd} gibi fonksiyon adlari ID kalir)
116
+ res, i, n = [], 0, len(out)
117
+ while i < n:
118
+ t = out[i]
119
+ if t[0] == "CMD" and t[1] in _UNIT_TEXT and i + 1 < n and out[i + 1][:2] == ("OP", "{"):
120
+ j = i + 2
121
+ while j < n and out[j][:2] != ("OP", "}"):
122
+ j += 1
123
+ if j < n:
124
+ inner = [x for x in out[i + 2:j] if x[0] != "SP"]
125
+ if len(inner) == 1 and inner[0][0] == "ID" and inner[0][1] in _FUNC_WORDS:
126
+ res.append(("ID", inner[0][1], t[2], out[j][3]))
127
+ else:
128
+ res.append(("UNIT", "", t[2], out[j][3]))
129
+ i = j + 1
130
+ continue
131
+ res.append(t)
132
+ i += 1
133
+ return res
134
+
135
+
136
+ # --------------------------------------------------------------------- parser
137
+ class _Fail(Exception):
138
+ pass
139
+
140
+
141
+ def _mk(name, *args):
142
+ return ("call", name, list(args))
143
+
144
+
145
+ def is_call(n):
146
+ return isinstance(n, tuple) and n[0] == "call"
147
+
148
+
149
+ def is_sym(n):
150
+ return isinstance(n, tuple) and n[0] == "sym"
151
+
152
+
153
+ def has_sym(n):
154
+ if is_sym(n):
155
+ return True
156
+ if is_call(n):
157
+ return any(has_sym(a) for a in n[2])
158
+ return False
159
+
160
+
161
+ def count_ops(n):
162
+ if is_call(n):
163
+ return 1 + sum(count_ops(a) for a in n[2])
164
+ return 0
165
+
166
+
167
+ def literal_like(n):
168
+ """Kendisi bir 'sayi yazimi' olan ifade: 3, -3, 3/4, sqrt(3), 2*sqrt(3), pi, 2*pi ..."""
169
+ if isinstance(n, Fraction):
170
+ return True
171
+ if not is_call(n):
172
+ return False
173
+ name, args = n[1], n[2]
174
+ if name == "pi":
175
+ return True
176
+ if name == "neg":
177
+ return literal_like(args[0])
178
+ if name == "div":
179
+ a, b = args
180
+ if isinstance(a, Fraction) and isinstance(b, Fraction):
181
+ import math as _m
182
+ return (a.denominator == 1 and b.denominator == 1 and b > 1
183
+ and _m.gcd(abs(a.numerator), b.numerator) == 1)
184
+ return all(literal_like(x) for x in args)
185
+ if name == "sqrt":
186
+ return isinstance(args[0], Fraction)
187
+ if name == "mul" and len(args) == 2:
188
+ return isinstance(args[0], Fraction) and is_call(args[1]) and args[1][1] in ("sqrt", "pi") \
189
+ and literal_like(args[1])
190
+ return False
191
+
192
+
193
+ class _Parser:
194
+ def __init__(self, toks, allow_syms=False, defined=()):
195
+ self.t, self.nl = [], []
196
+ pending = False
197
+ for k, v, *_ in toks:
198
+ if k == "SP":
199
+ pending = pending or v == "\n"
200
+ continue
201
+ self.t.append((k, v))
202
+ self.nl.append(pending)
203
+ pending = False
204
+ self.i = 0
205
+ self.end_nonunit = 0
206
+ self.allow_syms = allow_syms
207
+ self.defined = set(defined)
208
+ self.boxed = False
209
+
210
+ def sym_ok(self, v):
211
+ if len(v) == 1:
212
+ return v.isalpha() and (self.allow_syms or v in _NL_SYMS)
213
+ return 2 <= len(v) <= 3 and v.isalpha() and v.isupper() and v not in _CURRENCY
214
+
215
+ def peek(self, off=0):
216
+ j = self.i + off
217
+ return self.t[j] if j < len(self.t) else (None, None)
218
+
219
+ def eat(self, kind=None, val=None):
220
+ k, v = self.peek()
221
+ if k is None or (kind and k != kind) or (val is not None and v != val):
222
+ raise _Fail
223
+ self.i += 1
224
+ return v
225
+
226
+ def at(self, kind, val=None):
227
+ k, v = self.peek()
228
+ return k == kind and (val is None or v == val)
229
+
230
+ def _is_unit(self):
231
+ if self.i < len(self.nl) and self.nl[self.i]:
232
+ return False
233
+ k, v = self.peek()
234
+ if k == "UNIT":
235
+ return True
236
+ if k == "ID" and v.islower() and v not in _STOP and v not in _FUNC_WORDS and v != "pi" \
237
+ and len(v) <= 14 and not self.sym_ok(v):
238
+ nk, nv = self.peek(1)
239
+ return not (nk == "OP" and nv == "(")
240
+ return False
241
+
242
+ def _skip_units(self):
243
+ c = 0
244
+ while c < 3 and self._is_unit():
245
+ self.i += 1
246
+ c += 1
247
+ if self.peek() == ("ID", "per") and self.peek(1)[0] == "ID":
248
+ self.i += 2
249
+ # km/h, slices/pack gibi bolme isaretli birimler
250
+ while self.peek() == ("OP", "/") and self.peek(1)[0] in ("ID", "UNIT") and \
251
+ self.peek(1)[0] == "ID" and self.peek(1)[1].islower() and \
252
+ self.peek(1)[1] not in _STOP and self.peek(2) != ("OP", "("):
253
+ self.i += 2
254
+ while self.peek()[0] == "UNIT":
255
+ self.i += 1
256
+
257
+ def expr(self):
258
+ acc = self.term()
259
+ pure_add = False
260
+ while self.at("OP", "+") or self.at("OP", "-"):
261
+ if self.at("OP", "+") and self.peek(1) == ("OTHER", "⋯") and self.peek(2) == ("OP", "+"):
262
+ terms = acc[2] if (is_call(acc) and acc[1] == "add" and pure_add) else None
263
+ if terms is None or len(terms) < 2 or not all(isinstance(x, Fraction) for x in terms):
264
+ raise _Fail
265
+ d = terms[1] - terms[0]
266
+ if d == 0 or any(terms[i + 1] - terms[i] != d for i in range(len(terms) - 1)):
267
+ raise _Fail
268
+ self.eat(); self.eat(); self.eat()
269
+ last = self.term()
270
+ if not isinstance(last, Fraction):
271
+ raise _Fail
272
+ cnt = (last - terms[0]) / d + 1
273
+ if cnt.denominator != 1 or cnt < len(terms) + 1 or cnt > 100000:
274
+ raise _Fail
275
+ acc = _mk("arith_sum", terms[0], d, cnt)
276
+ pure_add = False
277
+ continue
278
+ op = self.eat()
279
+ rhs = self.term()
280
+ if op == "+":
281
+ if pure_add:
282
+ acc[2].append(rhs)
283
+ else:
284
+ acc = _mk("add", acc, rhs)
285
+ pure_add = True
286
+ else:
287
+ acc = _mk("sub", acc, rhs)
288
+ pure_add = False
289
+ return acc
290
+
291
+ def _starts_primary(self):
292
+ if self.i < len(self.nl) and self.nl[self.i]:
293
+ return False
294
+ k, v = self.peek()
295
+ if k == "OP" and v in ("(", "{"):
296
+ return True
297
+ if k == "CMD" and (v in ("\\frac", "\\dfrac", "\\tfrac", "\\sqrt", "\\pi") or v in _CMD_FUNCS):
298
+ return True
299
+ if k == "SQRT":
300
+ return True
301
+ if k == "ID" and (v == "pi" or v in _FUNC_WORDS):
302
+ return True
303
+ if k == "ID" and self.sym_ok(v):
304
+ return True
305
+ return False
306
+
307
+ def term(self):
308
+ acc = self.unary()
309
+ pure_mul = False
310
+ while True:
311
+ if self.at("OP", "*"):
312
+ self.eat()
313
+ rhs = self.unary()
314
+ if pure_mul:
315
+ acc[2].append(rhs)
316
+ else:
317
+ acc = _mk("mul", acc, rhs)
318
+ pure_mul = True
319
+ elif self.at("OP", "/"):
320
+ self.eat()
321
+ acc = _mk("div", acc, self.unary())
322
+ pure_mul = False
323
+ elif self._starts_primary():
324
+ rhs = self.unary()
325
+ if pure_mul:
326
+ acc[2].append(rhs)
327
+ else:
328
+ acc = _mk("mul", acc, rhs)
329
+ pure_mul = True
330
+ else:
331
+ break
332
+ return acc
333
+
334
+ def unary(self):
335
+ if self.at("OP", "-"):
336
+ self.eat()
337
+ v = self.unary()
338
+ return -v if isinstance(v, Fraction) else _mk("neg", v)
339
+ if self.at("OP", "+"):
340
+ self.eat()
341
+ return self.unary()
342
+ return self.power()
343
+
344
+ def power(self):
345
+ base = self.postfix()
346
+ if self.at("OP", "^"):
347
+ self.eat()
348
+ if self.at("CMD", "\\circ") or (self.at("OP", "{") and self.peek(1) == ("CMD", "\\circ")):
349
+ if self.at("OP", "{"):
350
+ self.eat(); self.eat(); self.eat("OP", "}")
351
+ else:
352
+ self.eat()
353
+ return _mk("deg", base)
354
+ return _mk("pow", base, self.exponent())
355
+ return base
356
+
357
+ def exponent(self):
358
+ if self.at("OP", "{"):
359
+ self.eat()
360
+ e = self.expr()
361
+ self.eat("OP", "}")
362
+ return e
363
+ if self.at("OP", "-"):
364
+ self.eat()
365
+ v = self.exponent()
366
+ return -v if isinstance(v, Fraction) else _mk("neg", v)
367
+ base = self.postfix(units=False)
368
+ if self.at("OP", "^"):
369
+ self.eat()
370
+ return _mk("pow", base, self.exponent())
371
+ return base
372
+
373
+ def postfix(self, units=True):
374
+ node = self.primary()
375
+ while True:
376
+ if self.at("OP", "!"):
377
+ self.eat()
378
+ node = _mk("fact", node)
379
+ elif self.at("DEG"):
380
+ self.eat()
381
+ node = _mk("deg", node)
382
+ elif self.at("OP", "%"):
383
+ self.eat()
384
+ if self.at("OP", "*"):
385
+ node = _mk("div", node, Fraction(100))
386
+ else:
387
+ break
388
+ self.end_nonunit = self.i
389
+ if units:
390
+ self._skip_units()
391
+ return node
392
+
393
+ def _group(self, open_, close):
394
+ self.eat("OP", open_)
395
+ e = self.expr()
396
+ self.eat("OP", close)
397
+ return e
398
+
399
+ def primary(self):
400
+ k, v = self.peek()
401
+ if k == "DOLLAR":
402
+ self.eat()
403
+ return self.primary()
404
+ if k == "CMD" and v in ("\\sum", "\\prod"):
405
+ self.eat()
406
+ self.eat("OTHER", "_")
407
+ self.eat("OP", "{")
408
+ var = self.eat("ID")
409
+ if len(var) != 1:
410
+ raise _Fail
411
+ self.eat("OTHER", "≔")
412
+ lo = self.expr()
413
+ self.eat("OP", "}")
414
+ self.eat("OP", "^")
415
+ hi = self._group("{", "}") if self.at("OP", "{") else self.primary()
416
+ body = self.term()
417
+ if not (isinstance(lo, Fraction) and isinstance(hi, Fraction)):
418
+ raise _Fail
419
+ return _mk("sum_range" if v == "\\sum" else "prod_range", infix(body), var, lo, hi)
420
+ if k == "NUM" and v.isdigit() and self.peek(1) == ("OTHER", "_"):
421
+ if self.peek(2)[0] == "NUM":
422
+ bt, adv = self.peek(2)[1], 3
423
+ elif self.peek(2) == ("OP", "{") and self.peek(3)[0] == "NUM" and self.peek(4) == ("OP", "}"):
424
+ bt, adv = self.peek(3)[1], 5
425
+ else:
426
+ bt, adv = None, 0
427
+ if bt is not None and bt.isdigit() and 2 <= int(bt) <= 16:
428
+ try:
429
+ int(v, int(bt))
430
+ except ValueError:
431
+ raise _Fail
432
+ self.i += adv
433
+ return _mk("from_base", v, Fraction(int(bt)))
434
+ if k == "NUM":
435
+ if (v.isdigit() and self.peek(1)[0] == "NUM" and self.peek(1)[1].isdigit()
436
+ and self.peek(2) == ("OP", "/") and self.peek(3)[0] == "NUM" and self.peek(3)[1].isdigit()
437
+ and not self.nl[self.i + 1] and int(self.peek(1)[1]) < int(self.peek(3)[1])):
438
+ w = Fraction(int(v))
439
+ a = Fraction(int(self.peek(1)[1]))
440
+ b = Fraction(int(self.peek(3)[1]))
441
+ self.i += 4
442
+ return _mk("add", w, _mk("div", a, b))
443
+ self.eat()
444
+ return Fraction(Decimal(v.replace(",", "").replace("\\!", "").replace("{", "").replace("}", "")))
445
+ if k == "OP" and v == "(":
446
+ return self._group("(", ")")
447
+ if k == "OP" and v == "{":
448
+ return self._group("{", "}")
449
+ if k == "OTHER" and v == "|":
450
+ self.eat()
451
+ e = self.expr()
452
+ self.eat("OTHER", "|")
453
+ return _mk("abs", e)
454
+ if k == "CMD" and v == "\\boxed":
455
+ self.eat()
456
+ e = self._group("{", "}")
457
+ self.boxed = True
458
+ return e
459
+ if k == "CMD" and v in ("\\frac", "\\dfrac", "\\tfrac"):
460
+ self.eat()
461
+ if self.at("NUM"):
462
+ d = self.eat()
463
+ if len(d) >= 2 and d.isdigit():
464
+ return _mk("div", Fraction(int(d[0])), Fraction(int(d[1:])))
465
+ a = Fraction(Decimal(d))
466
+ return _mk("div", a, self.primary())
467
+ a = self._group("{", "}")
468
+ b = self._group("{", "}")
469
+ return _mk("div", a, b)
470
+ if k == "CMD" and v in _BINOM:
471
+ self.eat()
472
+ a = self._group("{", "}")
473
+ b = self._group("{", "}")
474
+ return _mk("comb", a, b)
475
+ if (k == "ID" and v in ("gcd", "lcm")) or (k == "CMD" and v in ("\\gcd", "\\lcm")):
476
+ name = v.lstrip("\\")
477
+ self.eat()
478
+ self.eat("OP", "(")
479
+ args = [self.expr()]
480
+ while self.at("OTHER", ","):
481
+ self.eat()
482
+ args.append(self.expr())
483
+ self.eat("OP", ")")
484
+ if len(args) < 2:
485
+ raise _Fail
486
+ return _mk(name, *args)
487
+ if k == "CMD" and v == "\\sqrt":
488
+ self.eat()
489
+ if self.at("OP", "["):
490
+ self.eat()
491
+ n = self.expr()
492
+ self.eat("OP", "]")
493
+ a = self._group("{", "}")
494
+ return _mk("root", n, a)
495
+ if self.at("OP", "{"):
496
+ return _mk("sqrt", self._group("{", "}"))
497
+ return _mk("sqrt", self.primary())
498
+ if k == "SQRT":
499
+ self.eat()
500
+ if self.at("OP", "("):
501
+ return _mk("sqrt", self._group("(", ")"))
502
+ if self.at("NUM"):
503
+ return _mk("sqrt", self.primary())
504
+ raise _Fail
505
+ if (k == "CMD" and v == "\\pi") or (k == "ID" and v == "pi"):
506
+ self.eat()
507
+ return _mk("pi")
508
+ fname = None
509
+ if k == "CMD" and v in _CMD_FUNCS:
510
+ fname = _CMD_FUNCS[v]
511
+ elif k == "ID" and v in _FUNC_WORDS:
512
+ fname = v
513
+ if fname == "log" and self.peek(1) == ("OTHER", "_"):
514
+ self.eat(); self.eat()
515
+ if self.at("OP", "{"):
516
+ base = self._group("{", "}")
517
+ else:
518
+ base = self.primary()
519
+ if self.at("OP", "("):
520
+ arg = self._group("(", ")")
521
+ elif self.at("OP", "{"):
522
+ arg = self._group("{", "}")
523
+ else:
524
+ arg = self._arg_noexp()
525
+ return _mk("log", arg, base)
526
+ if fname:
527
+ self.eat()
528
+ if self.at("OP", "("):
529
+ arg = self._group("(", ")")
530
+ elif self.at("OP", "{"):
531
+ arg = self._group("{", "}")
532
+ else:
533
+ arg = self._arg_noexp()
534
+ return _mk(fname, arg)
535
+ if k == "ID" and v in _CURRENCY:
536
+ self.eat()
537
+ if self.at("OTHER", "."):
538
+ self.eat()
539
+ return self.primary()
540
+ if k == "ID" and (len(v) == 1 or self.sym_ok(v)) and v.isalpha():
541
+ if v in self.defined and self.peek(1) == ("OP", "("):
542
+ self.eat()
543
+ a = self._group("(", ")")
544
+ return _mk(v, a)
545
+ if self.sym_ok(v):
546
+ self.eat()
547
+ return ("sym", v + self._subscript())
548
+ raise _Fail
549
+
550
+ def _subscript(self):
551
+ if not self.at("OTHER", "_"):
552
+ return ""
553
+ self.eat()
554
+ if self.at("OP", "{"):
555
+ self.eat()
556
+ parts = []
557
+ while not self.at("OP", "}"):
558
+ k, v = self.peek()
559
+ if k is None or len(parts) > 6:
560
+ raise _Fail
561
+ self.i += 1
562
+ parts.append({"+": "p", "-": "m", "*": "t"}.get(v, v) if k == "OP" else v)
563
+ self.eat("OP", "}")
564
+ return "_" + re.sub(r"\W", "", "".join(parts))
565
+ k, v = self.peek()
566
+ if k in ("NUM", "ID"):
567
+ self.i += 1
568
+ return "_" + re.sub(r"\W", "", v)
569
+ raise _Fail
570
+
571
+ def _arg_noexp(self):
572
+ node = self.primary()
573
+ if self.at("DEG"):
574
+ self.eat()
575
+ return _mk("deg", node)
576
+ if self.at("OP", "^") and (self.peek(1) == ("CMD", "\\circ") or
577
+ (self.peek(1) == ("OP", "{") and self.peek(2) == ("CMD", "\\circ"))):
578
+ self.eat()
579
+ if self.at("OP", "{"):
580
+ self.eat(); self.eat(); self.eat("OP", "}")
581
+ else:
582
+ self.eat()
583
+ return _mk("deg", node)
584
+ return node
585
+
586
+
587
+ def parse_full(toks, allow_syms=False, defined=()):
588
+ p = _Parser(toks, allow_syms, defined)
589
+ try:
590
+ n = p.expr()
591
+ except (_Fail, IndexError):
592
+ return None
593
+ return n if p.i == len(p.t) else None
594
+
595
+
596
+ def parse_prefix(toks, allow_syms=False, defined=()):
597
+ """RHS: bir ifade ayristirir. (node, tuketilen_token_sayisi(birimsiz, SP dahil), boxed)"""
598
+ nonsp = [i for i, t in enumerate(toks) if t[0] != "SP"]
599
+ p = _Parser(toks, allow_syms, defined)
600
+ try:
601
+ n = p.expr()
602
+ except (_Fail, IndexError):
603
+ return None, 0, False
604
+ if p.end_nonunit == 0:
605
+ return None, 0, False
606
+ return n, nonsp[p.end_nonunit - 1] + 1, p.boxed
607
+
608
+
609
+ # --------------------------------------------------------- cikti bicimleyiciler
610
+ def render(n):
611
+ if isinstance(n, Fraction):
612
+ return fmt_frac(n)
613
+ if isinstance(n, str):
614
+ return json.dumps(n)
615
+ _, name, args = n
616
+ return f"{name}({', '.join(render(a) for a in args)})"
617
+
618
+
619
+ _PREC = {"add": 1, "sub": 1, "mul": 2, "div": 2, "neg": 2.5, "pow": 3}
620
+
621
+
622
+ def infix(n, parent=0):
623
+ """Sembolik arguman icin sympy'nin okuyabilecegi duz metin."""
624
+ if isinstance(n, Fraction):
625
+ if n.denominator == 1:
626
+ return str(n.numerator) if n >= 0 or parent < 2 else f"({n.numerator})"
627
+ return f"({n.numerator}/{n.denominator})"
628
+ if is_sym(n):
629
+ return n[1]
630
+ _, name, a = n
631
+ if name in ("add", "mul"):
632
+ op = " + " if name == "add" else "*"
633
+ s = op.join(infix(x, _PREC[name]) for x in a)
634
+ return f"({s})" if parent > _PREC[name] else s
635
+ if name == "sub":
636
+ s = f"{infix(a[0], 1)} - {infix(a[1], 2)}"
637
+ return f"({s})" if parent > 1 else s
638
+ if name == "div":
639
+ s = f"{infix(a[0], 2)}/{infix(a[1], 3)}"
640
+ return f"({s})" if parent > 2 else s
641
+ if name == "neg":
642
+ s = f"-{infix(a[0], 3)}"
643
+ return f"({s})" if parent > 2 else s
644
+ if name == "pow":
645
+ return f"{infix(a[0], 4)}^{infix(a[1], 4)}" if parent <= 3 else f"({infix(a[0], 4)}^{infix(a[1], 4)})"
646
+ if name == "fact":
647
+ return f"factorial({infix(a[0])})"
648
+ if name == "pi":
649
+ return "pi"
650
+ if name == "deg":
651
+ return f"({infix(a[0], 2)})*pi/180"
652
+ return f"{name}({', '.join(infix(x) for x in a)})"
653
+
654
+
655
+ def to_sympy(n):
656
+ if isinstance(n, Fraction):
657
+ return sympy.Rational(n.numerator, n.denominator)
658
+ if is_sym(n):
659
+ return sympy.Symbol(n[1])
660
+ _, name, a = n
661
+ if name == "from_base":
662
+ return sympy.Integer(int(a[0], int(a[1])))
663
+ if name in ("sum_range", "prod_range"):
664
+ return _back_sym(run(n))
665
+ v = [to_sympy(x) for x in a]
666
+ def _pw():
667
+ if v[1].is_number and (abs(v[1]) > 64 or (v[1].is_Rational and v[1].q > 8)):
668
+ raise ToolError("us cok buyuk")
669
+ if v[1].free_symbols and (v[0].free_symbols or v[1].count_ops() > 6):
670
+ raise ToolError("sembolik us")
671
+ return v[0] ** v[1]
672
+
673
+ m = {"add": lambda: sympy.Add(*v), "sub": lambda: v[0] - v[1], "mul": lambda: sympy.Mul(*v),
674
+ "div": lambda: v[0] / v[1], "pow": _pw, "neg": lambda: -v[0],
675
+ "fact": lambda: sympy.factorial(v[0]), "sqrt": lambda: sympy.sqrt(v[0]),
676
+ "root": lambda: sympy.root(v[1], v[0]), "pi": lambda: sympy.pi,
677
+ "deg": lambda: v[0] * sympy.pi / 180,
678
+ "arith_sum": lambda: v[2] * (2 * v[0] + (v[2] - 1) * v[1]) / 2, "ln": lambda: sympy.log(v[0]),
679
+ "log": lambda: sympy.log(v[0], v[1] if len(v) > 1 else 10), "exp": lambda: sympy.exp(v[0]), "abs": lambda: sympy.Abs(v[0])}
680
+ for f in ("sin", "cos", "tan", "cot", "sec", "csc", "asin", "acos", "atan"):
681
+ m[f] = (lambda f=f: getattr(sympy, f)(v[0]))
682
+ if name not in m:
683
+ raise ToolError(f"to_sympy: {name}")
684
+ return m[name]()
685
+
686
+
687
+ def _back_sym(x):
688
+ return sympy.Rational(x.numerator, x.denominator) if isinstance(x, Fraction) else x
689
+
690
+
691
+ def _finite_ends(sol):
692
+ out = []
693
+ if isinstance(sol, sympy.Union):
694
+ for a in sol.args:
695
+ out += _finite_ends(a)
696
+ elif isinstance(sol, sympy.Interval):
697
+ for e in (sol.start, sol.end):
698
+ if e.is_Rational:
699
+ out.append(Fraction(int(e.p), int(e.q)))
700
+ elif isinstance(sol, sympy.FiniteSet):
701
+ for e in sol.args:
702
+ if e.is_Rational:
703
+ out.append(Fraction(int(e.p), int(e.q)))
704
+ return out
705
+
706
+
707
+ def _same(a, b):
708
+ try:
709
+ if isinstance(a, Fraction):
710
+ a = sympy.Rational(a.numerator, a.denominator)
711
+ if isinstance(b, Fraction):
712
+ b = sympy.Rational(b.numerator, b.denominator)
713
+ d = a - b
714
+ if d == 0:
715
+ return True
716
+ if not d.free_symbols:
717
+ return abs(sympy.N(d, 40)) < sympy.Float("1e-30")
718
+ if d.count_ops() > 40:
719
+ return False
720
+ d = sympy.expand(d)
721
+ if d == 0:
722
+ return True
723
+ if d.has(sympy.sin, sympy.cos, sympy.tan, sympy.exp, sympy.log, sympy.factorial,
724
+ sympy.Abs, sympy.floor, sympy.ceiling):
725
+ return False
726
+ return sympy.cancel(d) == 0
727
+ except Exception:
728
+ return False
729
+
730
+
731
+ def _tag(call_str, result_str, prefix=""):
732
+ return f"{prefix}{CALL_OPEN}{call_str}{CALL_CLOSE}{RES_OPEN}{result_str}{RES_CLOSE}"
733
+
734
+
735
+ # ----------------------------------------------------------- on isleme (uzunluk koruyan)
736
+ _GSM_RE = re.compile(r"<<([^<>=]+)=([^<>]+)>>")
737
+ _LATEX_HINT = re.compile(r"\\(?:frac|dfrac|sqrt|times|cdot|boxed|text|left|right|div|pi|ldots|cdots|"
738
+ r"le|ge|neq|pm|sin|cos|tan|log|ln|binom|mathrm|begin|circ|approx|"
739
+ r"Rightarrow|implies)\b|\^\{|\\\(|\\\[")
740
+ _REGION = re.compile(r"\$\$(.+?)\$\$|\$([^$\n]+?)\$|\\\((.+?)\\\)|\\\[(.+?)\\\]", re.S)
741
+ _ALLOWED_OPS = set("+-*/^(){}!%")
742
+ _X_MULT = re.compile(r"(?<=[\d)])(\s*|\s+(?:[A-Za-z]+\s+){1,2})[xX](\s*)(?=[\d($])")
743
+ _CODE_HINT = re.compile(r"^\s*(print\(|def |import |from \w+ import |for .+ in |return )", re.M)
744
+
745
+
746
+ def _pad(rep):
747
+ return lambda m: rep + " " * (len(m.group(0)) - len(rep))
748
+
749
+
750
+ _WORD_SUBS = [
751
+ (re.compile(r"(?<=[\d)}])\s+divided by\s+(?=[\d(\\$-])", re.I), _pad("/")),
752
+ (re.compile(r"(?<=[\d)}])\s+(?:multiplied by|times)\s+(?=[\d(\\$-])", re.I), _pad("*")),
753
+ (re.compile(r"(?<=[\d)}])\s+plus\s+(?=[\d(\\$-])", re.I), _pad("+")),
754
+ (re.compile(r"(?<=[\d)}])\s+minus\s+(?=[\d(\\$-])", re.I), _pad("-")),
755
+ (re.compile(r"(?<=[\d)}])[\s,]*(?:which equals|which is equal to|is equal to|equals|equal to|gives)\s+"
756
+ r"(?=[\d(\\$-])", re.I), _pad("=")),
757
+ (re.compile(r"(?<=%)\s+of\s+(?=[\d(\\$])", re.I), _pad("*")),
758
+ ]
759
+
760
+
761
+ _ASY = re.compile(r"\[asy\].*?\[/asy\]", re.S)
762
+
763
+
764
+ _IS_EQ = re.compile(r"(\d[\d.,]*\s*[+\-*/]\s*\d[\d.,]*(?:\s*[+\-*/]\s*\d[\d.,]*)*)(\s+is\s+)(?=[\d$])")
765
+
766
+
767
+ def _is_to_eq(m):
768
+ sp = m.group(2)
769
+ return m.group(1) + " " + "=" + " " * (len(sp) - 2)
770
+
771
+
772
+ _SUM_HEAD = re.compile(r"(\\(?:sum|prod|lim)\s*_\s*\{[^{}=]*)=")
773
+
774
+
775
+ def _make_scan(text):
776
+ text = _ASY.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
777
+ s = _X_MULT.sub(lambda m: m.group(1) + "*" + m.group(2), text)
778
+ s = s.replace("&", " ")
779
+ s = _SUM_HEAD.sub(lambda m: m.group(1) + "≔", s)
780
+ s = s.replace("...", "⋯ ").replace("…", "⋯")
781
+ for rx, rep in _WORD_SUBS:
782
+ s = rx.sub(rep, s)
783
+ s = _IS_EQ.sub(_is_to_eq, s)
784
+ return s
785
+
786
+
787
+ def _dollar_math(text):
788
+ for m in re.finditer(r"\$([^$\n]{1,200}?)\$", text):
789
+ inner = re.sub(r"\\[A-Za-z]+", "", m.group(1))
790
+ if re.search(r"[A-Za-z]{2,}", inner):
791
+ continue
792
+ if re.search(r"(?<![A-Za-z])[A-Za-z](?![A-Za-z])", inner) and re.search(r"[=^_(]", inner):
793
+ return True
794
+ return False
795
+
796
+
797
+ _DBG = []
798
+
799
+
800
+ def _to_frac(v):
801
+ r = sympy.Rational(str(sympy.N(v, 40)))
802
+ return Fraction(int(r.p), int(r.q))
803
+
804
+
805
+ def _new_stats():
806
+ return dict(found=0, converted=0, mismatch=0)
807
+
808
+
809
+ def _fix_gsm_annotations(text, st):
810
+ out, pos = [], 0
811
+ for m in _GSM_RE.finditer(text):
812
+ expr, res = m.group(1).strip(), m.group(2).strip()
813
+ pre = text[pos:m.start()]
814
+ post_start = m.end()
815
+ node = parse_full(tokenize(expr))
816
+ ok, val = False, None
817
+ if node is not None and is_call(node):
818
+ rnode = parse_full(tokenize(res))
819
+ try:
820
+ val = run(node)
821
+ ok = rnode is not None and _same(val, run(rnode))
822
+ except Exception:
823
+ ok = False
824
+ st["found"] += 1
825
+ if ok:
826
+ pre2 = pre
827
+ body = pre.rstrip()
828
+ if body.endswith("="):
829
+ before = body[:-1].rstrip()
830
+ nrm = lambda z: re.sub(r"[\s$]", "", z)
831
+ for k in range(max(1, len(expr) - 3), len(expr) + 8):
832
+ if k <= len(before) and nrm(before[-k:]) == nrm(expr):
833
+ pre2 = before[:-k]
834
+ break
835
+ out.append(pre2)
836
+ rest = text[post_start:]
837
+ dup = re.match(re.escape(res) + r"(?![\d.])", rest)
838
+ out.append(_tag(render(node), to_text(val)))
839
+ st["converted"] += 1
840
+ pos = post_start + (dup.end() if dup else 0)
841
+ else:
842
+ st["mismatch"] += 1
843
+ out.append(pre)
844
+ pos = post_start
845
+ out.append(text[pos:])
846
+ return "".join(out)
847
+
848
+
849
+ _ENV_REGION = re.compile(r"\\begin\{(align|equation|gather|eqnarray|aligned|array)(\*?)\}(.+?)"
850
+ r"\\end\{\1\2\}", re.S)
851
+
852
+
853
+ def _regions(text):
854
+ regs = []
855
+ for m in _ENV_REGION.finditer(text):
856
+ regs.append((m.start(), m.end(), m.start(3), m.end(3), text[m.start():m.start(3)],
857
+ text[m.end(3):m.end()]))
858
+ taken = [(r[0], r[1]) for r in regs]
859
+ for m in _REGION.finditer(text):
860
+ if any(a <= m.start() and m.end() <= b for a, b in taken):
861
+ continue
862
+ g = next(i for i in range(1, 5) if m.group(i) is not None)
863
+ regs.append((m.start(), m.end(), m.start(g), m.end(g), text[m.start():m.start(g)],
864
+ text[m.end(g):m.end()]))
865
+ regs.sort(key=lambda r: r[0])
866
+ return regs
867
+
868
+
869
+ def _nums_after(text, pos):
870
+ vals = set()
871
+ for m in re.finditer(r"(?<![\d.])\d+(?:\.\d+)?", text[pos:]):
872
+ try:
873
+ vals.add(Fraction(Decimal(m.group(0))))
874
+ vals.add(-Fraction(Decimal(m.group(0))))
875
+ except Exception:
876
+ pass
877
+ for m in re.finditer(r"\\[dt]?frac\{(-?\d+)\}\{(\d+)\}", text[pos:]):
878
+ if int(m.group(2)):
879
+ f = Fraction(int(m.group(1)), int(m.group(2)))
880
+ vals.add(f)
881
+ vals.add(-f)
882
+ return vals
883
+
884
+
885
+ def convert_text(text: str, aggressive: bool = False):
886
+ st = _new_stats()
887
+ if _CODE_HINT.search(_ASY.sub("", text)):
888
+ return text, st
889
+ if "<<" in text:
890
+ text = _fix_gsm_annotations(text, st)
891
+ latex_mode = bool(_LATEX_HINT.search(text)) or _dollar_math(text)
892
+ regions = _regions(text) if latex_mode else []
893
+ starts = [r[0] for r in regions]
894
+
895
+ def region_at(cs, ce):
896
+ k = bisect.bisect_right(starts, cs) - 1
897
+ if k >= 0 and regions[k][0] <= cs and ce <= regions[k][1]:
898
+ return k
899
+ return None
900
+
901
+ scan = _make_scan(text)
902
+ toks = tokenize(scan)
903
+ n = len(toks)
904
+ env = {} # tanimli fonksiyonlar: ad -> (vars, sympy_body)
905
+ edits = [] # (cs, ce, call_str, result_str, prefix, boxed_result)
906
+ solve_cands = [] # (cs, ce, node_l, node_r, after_pos)
907
+ last_end = 0
908
+
909
+ def allowed_left(t, pos_in_region):
910
+ k, v = t[0], t[1]
911
+ if k in ("NUM", "SP", "SQRT", "DEG", "UNIT"):
912
+ return True
913
+ if k == "OP":
914
+ return v in _ALLOWED_OPS
915
+ if k == "OTHER":
916
+ return v in (",", "|", "_", "⋯", "≔")
917
+ if k == "DOLLAR":
918
+ return not latex_mode
919
+ if k == "CMD":
920
+ return v in _CMD_ALLOWED
921
+ if k == "ID":
922
+ if v in _FUNC_WORDS or v == "pi" or v in _CURRENCY:
923
+ return True
924
+ if len(v) == 1 and v.isalpha() and ((latex_mode and pos_in_region) or v in _NL_SYMS):
925
+ return True
926
+ if 2 <= len(v) <= 3 and v.isupper() and v.isalpha():
927
+ return True
928
+ if v.islower() and v not in _STOP and len(v) <= 14:
929
+ return True # birim kelimesi olabilir
930
+ return False
931
+ return False
932
+
933
+ for i, t in enumerate(toks):
934
+ if t[0] != "OTHER" or t[1] not in ("=", "≈", "<", ">", "≤", "≥"):
935
+ continue
936
+ approx = t[1] == "≈"
937
+ rel = t[1] in ("<", ">", "≤", "≥")
938
+ if rel:
939
+ prv = toks[i - 1] if i > 0 else None
940
+ nx = toks[i + 1] if i + 1 < n else None
941
+ if (prv and prv[0] in ("OP", "OTHER") and prv[1] in ("-", "=", "<", ">")) or \
942
+ (nx and nx[0] == "OTHER" and nx[1] in ("<", ">")):
943
+ continue
944
+ if i + 1 < n and toks[i + 1][0] == "OTHER" and toks[i + 1][1] == "=":
945
+ continue
946
+ if i > 0 and toks[i - 1][0] == "OTHER" and toks[i - 1][1] in "=<>!≠":
947
+ continue
948
+ in_reg = region_at(t[2], t[3]) is not None
949
+ allow_syms = latex_mode and in_reg
950
+ j = i + 1
951
+ while j < n and toks[j][0] == "SP":
952
+ j += 1
953
+ if j >= n:
954
+ continue
955
+ rhs, used, boxed = parse_prefix(toks[j:], allow_syms, env.keys())
956
+ if rhs is None:
957
+ continue
958
+ r_end = j + used
959
+ nxt = r_end
960
+ while nxt < n and toks[nxt][0] == "SP":
961
+ nxt += 1
962
+ if nxt < n:
963
+ k2, v2 = toks[nxt][0], toks[nxt][1]
964
+ gap = scan[toks[r_end - 1][3]:toks[nxt][2]]
965
+ if "\n" not in gap and ((k2 == "OP" and v2 in "^(!") or k2 in ("NUM", "DEG", "SQRT") or
966
+ (k2 == "CMD" and v2 in _CMD_ALLOWED and v2 not in ("\\circ", "\\boxed"))):
967
+ continue
968
+ chain_next = nxt < n and toks[nxt][0] == "OTHER" and toks[nxt][1] == "="
969
+ # sol taraf
970
+ L = i
971
+ while L - 1 >= 0 and allowed_left(toks[L - 1], region_at(toks[L - 1][2], toks[L - 1][3]) is not None):
972
+ L -= 1
973
+ while L < i and toks[L][0] == "SP":
974
+ L += 1
975
+ found = None
976
+ for s in range(L, min(i, L + 60)):
977
+ if toks[s][0] == "SP":
978
+ continue
979
+ lhs_toks = toks[s:i]
980
+ # f(x) = ... tanimi
981
+ nonsp = [x for x in lhs_toks if x[0] != "SP"]
982
+ if (allow_syms and len(nonsp) >= 4 and nonsp[0][0] == "ID" and len(nonsp[0][1]) == 1
983
+ and nonsp[1][:2] == ("OP", "(") and nonsp[-1][:2] == ("OP", ")")
984
+ and all(x[0] == "ID" and len(x[1]) == 1 or x[:2] == ("OTHER", ",") for x in nonsp[2:-1])
985
+ and not (s > 0 and toks[s - 1][0] == "ID")):
986
+ found = (s, ("define", nonsp[0][1], [x[1] for x in nonsp[2:-1] if x[0] == "ID"]))
987
+ break
988
+ node = parse_full(lhs_toks, allow_syms, env.keys())
989
+ if node is None or isinstance(node, Fraction) or is_sym(node):
990
+ continue
991
+ q = s - 1
992
+ while q >= 0 and toks[q][0] == "SP":
993
+ q -= 1
994
+ if q >= 0:
995
+ kq, vq = toks[q][0], toks[q][1]
996
+ adjacent = (q == s - 1)
997
+ fk, fv = toks[s][0], toks[s][1]
998
+ if kq == "ID" and (fk == "OP" and fv in "+-*/^" or adjacent):
999
+ continue
1000
+ if kq == "OP" and vq in (")", "}", "^", "_") and fk == "OP":
1001
+ continue
1002
+ if kq == "OTHER" and vq == "_":
1003
+ continue
1004
+ if adjacent and kq in ("NUM", "ID"):
1005
+ continue
1006
+ if kq == "NUM" and fk == "NUM":
1007
+ continue
1008
+ found = (s, node)
1009
+ break
1010
+ if found is None and rel:
1011
+ continue
1012
+ if found is None:
1013
+ # "Sayi = 45 - 21" gibi: sol taraf etiket/degisken, sag taraf sayisal islem
1014
+ if approx or boxed or has_sym(rhs) or not is_call(rhs) or literal_like(rhs):
1015
+ continue
1016
+ if nxt < n and toks[nxt][0] == "OTHER" and toks[nxt][1] in ("=", "≈"):
1017
+ continue
1018
+ cs = toks[j][2]
1019
+ ce = toks[r_end - 1][3]
1020
+ if cs < last_end:
1021
+ continue
1022
+ try:
1023
+ v_ = run(rhs, env)
1024
+ except Exception:
1025
+ continue
1026
+ if isinstance(v_, str):
1027
+ continue
1028
+ st["found"] += 1
1029
+ edits.append((cs, ce, render(rhs), to_text(v_), "$" if toks[j][0] == "DOLLAR" and not latex_mode
1030
+ else "", None))
1031
+ last_end = ce
1032
+ st["converted"] += 1
1033
+ continue
1034
+ s, lhs = found
1035
+ cs = toks[s][2]
1036
+ ce = toks[r_end - 1][3]
1037
+ if cs < last_end:
1038
+ continue
1039
+ st["found"] += 1
1040
+
1041
+ # ---- fonksiyon tanimi
1042
+ if isinstance(lhs, tuple) and lhs and lhs[0] == "define":
1043
+ if not has_sym(rhs) or boxed:
1044
+ continue
1045
+ try:
1046
+ body = infix(rhs)
1047
+ vars_ = lhs[2]
1048
+ env[lhs[1]] = (vars_, _sym(body))
1049
+ edits.append((cs, ce, f'define("{lhs[1]}", "{", ".join(vars_)}", "{body}")',
1050
+ f"{lhs[1]}({', '.join(vars_)}) = {body}", "", None))
1051
+ last_end = ce
1052
+ st["converted"] += 1
1053
+ except Exception:
1054
+ pass
1055
+ continue
1056
+
1057
+ sym_l, sym_r = has_sym(lhs), has_sym(rhs)
1058
+ call_str = res_str = None
1059
+ if rel and not (sym_l or sym_r):
1060
+ continue
1061
+ try:
1062
+ if not sym_l and not sym_r:
1063
+ lv = run(lhs, env)
1064
+ rv = run(rhs, env)
1065
+ if approx:
1066
+ if not (isinstance(rhs, Fraction) and isinstance(lv, (Fraction,)) or True):
1067
+ continue
1068
+ rtxt = text[toks[j][2]:toks[r_end - 1][3]]
1069
+ mm = re.search(r"\.(\d+)", rtxt)
1070
+ nd = len(mm.group(1)) if mm else 0
1071
+ if not isinstance(rhs, Fraction):
1072
+ continue
1073
+ lvf = lv if isinstance(lv, Fraction) else Fraction(float(sympy.N(lv, 30)))
1074
+ if _round_half_up(lvf, nd) != rhs:
1075
+ st["mismatch"] += 1
1076
+ continue
1077
+ call_str = f"round({render(lhs)}, {nd})"
1078
+ res_str = to_text(rhs)
1079
+ else:
1080
+ if not _same(lv, rv):
1081
+ rtxt = text[toks[j][2]:toks[r_end - 1][3]]
1082
+ mm = re.search(r"\.(\d+)", rtxt)
1083
+ if isinstance(rhs, Fraction) and mm and not boxed or (isinstance(rhs, Fraction) and mm):
1084
+ nd = len(mm.group(1))
1085
+ try:
1086
+ lvf = lv if isinstance(lv, Fraction) else _to_frac(lv)
1087
+ if _round_half_up(lvf, nd) == rhs and is_call(lhs):
1088
+ edits.append((cs, ce, f"round({render(lhs)}, {nd})", to_text(rhs), "",
1089
+ to_text(rv) if boxed else None))
1090
+ last_end = ce
1091
+ st["converted"] += 1
1092
+ continue
1093
+ except Exception:
1094
+ pass
1095
+ if isinstance(rhs, Fraction) and rhs.denominator == 1 and is_call(lhs):
1096
+ try:
1097
+ import math as _m
1098
+ lvf = lv if isinstance(lv, Fraction) else _to_frac(lv)
1099
+ if lvf.denominator != 1 and abs(lvf - rhs) < 1:
1100
+ if _round_half_up(lvf, 0) == rhs:
1101
+ call_i = f"round({render(lhs)}, 0)"
1102
+ elif Fraction(_m.floor(lvf)) == rhs:
1103
+ call_i = f"floor({render(lhs)})"
1104
+ else:
1105
+ call_i = None
1106
+ if call_i:
1107
+ edits.append((cs, ce, call_i, to_text(rhs), "",
1108
+ to_text(rv) if boxed else None))
1109
+ last_end = ce
1110
+ st["converted"] += 1
1111
+ continue
1112
+ except Exception:
1113
+ pass
1114
+ st["mismatch"] += 1
1115
+ continue
1116
+ if not literal_like(rhs) and not boxed:
1117
+ # iki ifade de islem iceriyor: esitligi araca dogrulat
1118
+ if not is_call(rhs) or count_ops(rhs) >= count_ops(lhs) + 3 or chain_next:
1119
+ continue
1120
+ call_str = f'equal("{infix(lhs)}", "{infix(rhs)}")'
1121
+ res_str = "true"
1122
+ else:
1123
+ call_str = render(lhs)
1124
+ res_str = to_text(lv)
1125
+ else:
1126
+ if rel:
1127
+ L_, R_ = to_sympy(lhs), to_sympy(rhs)
1128
+ fs = (L_ - R_).free_symbols
1129
+ if len(fs) != 1 or boxed:
1130
+ continue
1131
+ var = next(iter(fs))
1132
+ diff_ = sympy.together(L_ - R_)
1133
+ if diff_.count_ops() > 50:
1134
+ continue
1135
+ if sympy.Poly(sympy.fraction(diff_)[0], var).degree() > 4:
1136
+ continue
1137
+ relcls = {"<": sympy.Lt, ">": sympy.Gt, "≤": sympy.Le, "≥": sympy.Ge}[t[1]]
1138
+ sol = sympy.solve_univariate_inequality(relcls(L_, R_), var, relational=False)
1139
+ ends = _finite_ends(sol)
1140
+ seen = _nums_after(text, ce)
1141
+ if not ends or not any(e_ in seen for e_ in ends):
1142
+ continue
1143
+ opstr = {"<": "<", ">": ">", "≤": "<=", "≥": ">="}[t[1]]
1144
+ call_ = f'solve("{infix(lhs)} {opstr} {infix(rhs)}", "{var}")'
1145
+ res_ = execute(call_)
1146
+ edits.append((cs, ce, call_, res_, "", None))
1147
+ last_end = ce
1148
+ st["converted"] += 1
1149
+ continue
1150
+ if approx or isinstance(lhs, Fraction):
1151
+ continue
1152
+ L_, R_ = to_sympy(lhs), to_sympy(rhs)
1153
+ if _same(L_, R_):
1154
+ # ozdeslik: LHS'i en uygun fonksiyonla yeniden yaz, RHS ile ayni olmali
1155
+ if sym_l and not sym_r:
1156
+ continue
1157
+ lhs_s = infix(lhs)
1158
+ for fn, f in (("expand", sympy.expand), ("factor", sympy.factor),
1159
+ ("simplify", sympy.simplify)):
1160
+ cand = f(L_)
1161
+ if cand != L_ and _same(cand, R_):
1162
+ call_str = f'{fn}("{lhs_s}")'
1163
+ res_str = to_text(cand)
1164
+ break
1165
+ if call_str is None:
1166
+ if (not is_call(rhs) and not has_sym(rhs)) or chain_next:
1167
+ continue
1168
+ call_str = f'equal("{lhs_s}", "{infix(rhs)}")'
1169
+ res_str = "true"
1170
+ else:
1171
+ # denklem: solve adayi
1172
+ fs = (L_ - R_).free_symbols
1173
+ if len(fs) == 1 and (is_call(lhs) or is_call(rhs)) and not boxed:
1174
+ solve_cands.append((cs, ce, lhs, rhs, ce, next(iter(fs))))
1175
+ continue
1176
+ except Exception:
1177
+ continue
1178
+ edits.append((cs, ce, call_str, res_str, "", to_text(rv) if boxed and not sym_l and not sym_r
1179
+ and not approx else None))
1180
+ # $5 * 3 = $15 bicimi (para birimi)
1181
+ if not latex_mode and toks[s][0] == "DOLLAR":
1182
+ edits[-1] = edits[-1][:4] + ("$",) + edits[-1][5:]
1183
+ last_end = ce
1184
+ st["converted"] += 1
1185
+
1186
+ # ---- denklem cozme (solve): sonucu metinde sonradan gecen kokler
1187
+ taken = [(e[0], e[1]) for e in edits]
1188
+ for cs, ce, lhs, rhs, after, var in solve_cands:
1189
+ if any(a < ce and cs < b for a, b in taken):
1190
+ continue
1191
+ try:
1192
+ le, re_ = to_sympy(lhs), to_sympy(rhs)
1193
+ diff_ = sympy.together(le - re_)
1194
+ if diff_.count_ops() > 50:
1195
+ continue
1196
+ numer = sympy.fraction(diff_)[0]
1197
+ if sympy.Poly(numer, var).degree() > 4:
1198
+ continue
1199
+ sols = sympy.solve(sympy.Eq(le, re_), var)
1200
+ except Exception:
1201
+ continue
1202
+ if not sols or len(sols) > 4 or not all(getattr(s_, "is_real", False) for s_ in sols):
1203
+ continue
1204
+ seen = _nums_after(text, after)
1205
+ rat = [Fraction(int(s_.p), int(s_.q)) for s_ in sols if s_.is_Rational]
1206
+ if not rat or not any(r in seen for r in rat):
1207
+ continue
1208
+ sols = sorted(sols, key=lambda z: float(z))
1209
+ call = f'solve("{infix(lhs)} = {infix(rhs)}", "{var}")'
1210
+ try:
1211
+ res = execute(call)
1212
+ except Exception:
1213
+ continue
1214
+ edits.append((cs, ce, call, res, "", None))
1215
+ taken.append((cs, ce))
1216
+ st["converted"] += 1
1217
+
1218
+ # ---- agresif mod: "=" olmayan, yalnizca sayilardan olusan islem ifadeleri
1219
+ if aggressive:
1220
+ occupied = [(e[0], e[1]) for e in edits]
1221
+ k = 0
1222
+ while k < n:
1223
+ tk = toks[k]
1224
+ is_start = tk[0] == "NUM" or (tk[0] == "OP" and tk[1] == "(")
1225
+ if not is_start or any(a < tk[3] and tk[2] < b for a, b in occupied):
1226
+ k += 1
1227
+ continue
1228
+ if k > 0 and toks[k - 1][0] in ("ID", "NUM") or (tk[2] > 0 and scan[tk[2] - 1] in "_."):
1229
+ k += 1
1230
+ continue
1231
+ j = k
1232
+ while j < n:
1233
+ tj = toks[j]
1234
+ if tj[0] in ("NUM", "DEG", "SQRT"):
1235
+ j += 1
1236
+ elif tj[0] == "SP":
1237
+ if "\n" in scan[tj[2]:tj[3]]:
1238
+ break
1239
+ j += 1
1240
+ elif tj[0] == "OP" and tj[1] in "+-*/^()!":
1241
+ j += 1
1242
+ else:
1243
+ break
1244
+ end, done = j, False
1245
+ while end - k >= 3:
1246
+ while end > k and (toks[end - 1][0] == "SP" or
1247
+ (toks[end - 1][0] == "OP" and toks[end - 1][1] in "+-*/^(")):
1248
+ end -= 1
1249
+ if end - k < 3:
1250
+ break
1251
+ node = parse_full(toks[k:end], False, env.keys())
1252
+ if node is not None and is_call(node) and not literal_like(node) and count_ops(node) >= 1:
1253
+ cs_, ce_ = toks[k][2], toks[end - 1][3]
1254
+ seg = text[cs_:ce_]
1255
+ pk = k - 1
1256
+ while pk >= 0 and toks[pk][0] == "SP" and "\n" not in scan[toks[pk][2]:toks[pk][3]]:
1257
+ pk -= 1
1258
+ nk = end
1259
+ while nk < n and toks[nk][0] == "SP" and "\n" not in scan[toks[nk][2]:toks[nk][3]]:
1260
+ nk += 1
1261
+ prev_t = toks[pk] if pk >= 0 else None
1262
+ next_t = toks[nk] if nk < n else None
1263
+
1264
+ def _ok_neighbor(t_):
1265
+ if t_ is None or t_[0] in ("SP", "DOLLAR", "UNIT"):
1266
+ return True
1267
+ if t_[0] == "OTHER":
1268
+ return t_[1] in ",;:.=≈?!"
1269
+ if t_[0] == "ID":
1270
+ return t_[1].islower() and len(t_[1]) > 1
1271
+ if t_[0] == "OP":
1272
+ return t_[1] in "%" and False
1273
+ return False
1274
+
1275
+ bad = (
1276
+ re.fullmatch(r"\d{1,4}\s*[-/]\s*\d{1,4}(\s*[-/]\s*\d{1,4})?", seg) is not None
1277
+ and " " not in seg and not latex_mode
1278
+ ) or any(a < ce_ and cs_ < b for a, b in occupied) \
1279
+ or region_at(cs_, ce_) is not None \
1280
+ or not _ok_neighbor(prev_t) or not _ok_neighbor(next_t)
1281
+ if not bad:
1282
+ try:
1283
+ v_ = run(node, env)
1284
+ except Exception:
1285
+ v_ = None
1286
+ if v_ is not None and not isinstance(v_, str) and len(to_text(v_)) <= 30:
1287
+ edits.append((cs_, ce_, render(node), to_text(v_), "", None))
1288
+ occupied.append((cs_, ce_))
1289
+ st["converted"] += 1
1290
+ done = True
1291
+ break
1292
+ end -= 1
1293
+ k = end if done else k + 1
1294
+
1295
+ # ---- $...$ icinde tek basina duran sayisal ifade
1296
+ for (a, b, ia, ib, op, cl) in regions:
1297
+ if any(x < b and a < y for x, y in taken):
1298
+ continue
1299
+ inner = text[ia:ib]
1300
+ if "=" in inner or not re.search(r"\d", inner):
1301
+ continue
1302
+ tk = tokenize(_make_scan(inner))
1303
+ node = parse_full(tk, False, env.keys())
1304
+ if node is None or not is_call(node) or literal_like(node) or count_ops(node) < 1:
1305
+ continue
1306
+ try:
1307
+ v = run(node, env)
1308
+ except Exception:
1309
+ continue
1310
+ if isinstance(v, str):
1311
+ continue
1312
+ edits.append((a, b, render(node), to_text(v), "", "REGION"))
1313
+ taken.append((a, b))
1314
+ st["converted"] += 1
1315
+
1316
+ if not edits:
1317
+ return text, st
1318
+ # son guvenlik: ciktidaki her cagri yeniden calistirilir, tutmayanlar geri alinir
1319
+ for _ in range(4):
1320
+ out = _apply(text, edits, regions, region_at)
1321
+ env2, failed = {}, set()
1322
+ for m in _TAG_RE.finditer(out):
1323
+ try:
1324
+ if to_text(run(parse_call(m.group(1)), env2)) != m.group(2):
1325
+ failed.add(m.group(1))
1326
+ except Exception:
1327
+ failed.add(m.group(1))
1328
+ if not failed:
1329
+ break
1330
+ edits = [e for e in edits if e[2] not in failed]
1331
+ st["converted"] = max(0, len(edits))
1332
+ if not edits:
1333
+ return text, st
1334
+ st["converted"] = len(edits)
1335
+ return out, st
1336
+
1337
+
1338
+ def _wrap(content, op, cl):
1339
+ return f"{op}{content}{cl}" if content.strip() else ""
1340
+
1341
+
1342
+ def _apply(text, edits, regions, region_at):
1343
+ edits = sorted(edits, key=lambda e: e[0])
1344
+ by_region, free = {}, []
1345
+ for e in edits:
1346
+ r = region_at(e[0], e[1])
1347
+ if r is not None and e[5] != "REGION":
1348
+ by_region.setdefault(r, []).append(e)
1349
+ else:
1350
+ free.append(e)
1351
+ # tam bolge degistirmeleri (REGION) ve serbest duzenlemeler
1352
+ out_edits = []
1353
+ for e in free:
1354
+ cs, ce, call, res, prefix, boxed = e
1355
+ if boxed == "REGION":
1356
+ out_edits.append((cs, ce, _tag(call, res)))
1357
+ else:
1358
+ tail = f"\\boxed{{{boxed}}}" if boxed else ""
1359
+ out_edits.append((cs, ce, _tag(call, res, prefix) + tail))
1360
+ for r, es in by_region.items():
1361
+ a, b, ia, ib, op, cl = regions[r]
1362
+ parts, pos = [], ia
1363
+ for cs, ce, call, res, prefix, boxed in es:
1364
+ parts.append(_wrap(text[pos:cs], op, cl))
1365
+ parts.append(_tag(call, res))
1366
+ if boxed:
1367
+ parts.append(f"{op}\\boxed{{{boxed}}}{cl}")
1368
+ pos = ce
1369
+ parts.append(_wrap(text[pos:ib], op, cl))
1370
+ out_edits.append((a, b, "".join(parts)))
1371
+ out_edits.sort(key=lambda e: e[0])
1372
+ out, pos = [], 0
1373
+ for cs, ce, new in out_edits:
1374
+ if cs < pos:
1375
+ continue
1376
+ out.append(text[pos:cs])
1377
+ out.append(new)
1378
+ pos = ce
1379
+ out.append(text[pos:])
1380
+ return "".join(out)
1381
+
1382
+
1383
+ _TAG_RE = re.compile(re.escape(CALL_OPEN) + r"(.+?)" + re.escape(CALL_CLOSE) + re.escape(RES_OPEN) +
1384
+ r"(.*?)" + re.escape(RES_CLOSE), re.S)
1385
+
1386
+
1387
+ def verify_converted(text: str):
1388
+ """Her cagriyi sirayla yeniden calistirir (define ortami korunur); (toplam, hatali)."""
1389
+ total = bad = 0
1390
+ env = {}
1391
+ for m in _TAG_RE.finditer(text):
1392
+ total += 1
1393
+ try:
1394
+ if to_text(run(parse_call(m.group(1)), env)) != m.group(2):
1395
+ bad += 1
1396
+ except Exception:
1397
+ bad += 1
1398
+ return total, bad
1399
+
1400
+
1401
+ if __name__ == "__main__":
1402
+ import sys
1403
+ sys.stdout.reconfigure(encoding="utf-8")
1404
+ tests = [
1405
+ "Natalia sold 48/2 = <<48/2=24>>24 clips in May. In total 48+24 = <<48+24=72>>72 clips. #### 72",
1406
+ "She earns $5 * 3 = $15 per day and 15 * 7 = $105 per week.",
1407
+ "We have $3 + 4 = 7$ and $\\frac{3}{4} \\times 8 = 6$. Also $\\sin 30^\\circ = \\frac{1}{2}$.",
1408
+ "Jungkook collected 6 divided by 3, which equals 2. Then 12 cm = 12 * 10 mm = 120 mm.",
1409
+ "So $x=\\frac{70}{8}=\\boxed{8.75}$. Also $1/3 \\approx 0.33$ and $100 \\cdot 2^{10}$ is large.",
1410
+ "Then $(x+1)^2 = x^2 + 2x + 1$. Solving $24 + 0.25s + \\frac{2}{3}s = s$ we get $s = \\boxed{288}$.",
1411
+ "Let $f(x) = 2x + 1$. Then $f(3) = 7$ and $20\\%$ of 50 is 10; 20% of 50 = 10.",
1412
+ ]
1413
+ for t in tests:
1414
+ o, st = convert_text(t)
1415
+ print("IN :", t)
1416
+ print("OUT:", o)
1417
+ print("ST :", st, "verify:", verify_converted(o))
1418
+ print()